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.
Files changed (300) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +325 -17
  3. data/README.md +52 -11
  4. data/bin/insika +895 -8
  5. data/bin/insika-router +87 -0
  6. data/docs/AGENTS.md +229 -163
  7. data/docs/API.md +56 -0
  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 +111 -21
  12. data/docs/CONTEXT.md +132 -19
  13. data/docs/DEMO.md +80 -0
  14. data/docs/DEPLOY.md +75 -4
  15. data/docs/EMBEDDING.md +1 -1
  16. data/docs/EVALS.md +146 -2
  17. data/docs/FACTS.md +135 -0
  18. data/docs/HARVEST.md +116 -0
  19. data/docs/KNOWLEDGE.md +290 -0
  20. data/docs/LOADTEST.md +16 -10
  21. data/docs/MEDIA.md +128 -0
  22. data/docs/OBSERVABILITY.md +65 -6
  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 +17 -12
  27. data/docs/RELEASING.md +27 -0
  28. data/docs/ROUTER.md +213 -0
  29. data/docs/RUNNING-LOCAL.md +6 -6
  30. data/docs/SCHEDULING.md +121 -0
  31. data/docs/SECURITY.md +79 -2
  32. data/docs/SKILLS.md +13 -4
  33. data/docs/SOAK.md +127 -0
  34. data/docs/TEMPLATES.md +134 -0
  35. data/docs/TOOLS.md +220 -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 +115 -0
  47. data/docs/improve.md +20 -0
  48. data/docs/index.md +9 -6
  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 +202 -10
  59. data/lib/insika/artifact_signing.rb +82 -0
  60. data/lib/insika/artifact_store.rb +160 -0
  61. data/lib/insika/balloon_splitter.rb +102 -0
  62. data/lib/insika/budget_ledger.rb +34 -3
  63. data/lib/insika/cache_series_store.rb +49 -0
  64. data/lib/insika/channel_delivery.rb +119 -14
  65. data/lib/insika/channels/relay.rb +77 -3
  66. data/lib/insika/channels/web.rb +2 -2
  67. data/lib/insika/chat_builder.rb +105 -2
  68. data/lib/insika/checkpoint_store.rb +16 -0
  69. data/lib/insika/commands/agent_payload.rb +3 -3
  70. data/lib/insika/commands/backfill_knowledge.rb +145 -0
  71. data/lib/insika/commands/cancel_followup.rb +49 -0
  72. data/lib/insika/commands/delete_artifact.rb +35 -0
  73. data/lib/insika/commands/delete_concept.rb +34 -0
  74. data/lib/insika/commands/delete_mcp.rb +6 -2
  75. data/lib/insika/commands/delete_tenant_data.rb +107 -0
  76. data/lib/insika/commands/export_customer_memory.rb +48 -0
  77. data/lib/insika/commands/forget_customer.rb +117 -0
  78. data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
  79. data/lib/insika/commands/gate_harvest.rb +138 -0
  80. data/lib/insika/commands/gate_refinement.rb +1 -1
  81. data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
  82. data/lib/insika/commands/memory_forget_fact.rb +20 -4
  83. data/lib/insika/commands/memory_put_fact.rb +23 -4
  84. data/lib/insika/commands/promote_harvest.rb +130 -0
  85. data/lib/insika/commands/record_outcome.rb +46 -0
  86. data/lib/insika/commands/record_shadow_reply.rb +68 -0
  87. data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
  88. data/lib/insika/commands/reject_harvest.rb +38 -0
  89. data/lib/insika/commands/resolve_proposal.rb +108 -0
  90. data/lib/insika/commands/restore_concept.rb +34 -0
  91. data/lib/insika/commands/revoke_contact.rb +49 -0
  92. data/lib/insika/commands/rollback_harvest.rb +86 -0
  93. data/lib/insika/commands/run_distillation.rb +186 -0
  94. data/lib/insika/commands/run_harvest.rb +393 -0
  95. data/lib/insika/commands/seed_demo_data.rb +31 -0
  96. data/lib/insika/commands/send_message.rb +103 -6
  97. data/lib/insika/commands/session_purge.rb +67 -0
  98. data/lib/insika/commands/upsert_mcp.rb +6 -3
  99. data/lib/insika/commands/write_concept.rb +57 -0
  100. data/lib/insika/contact_store.rb +183 -0
  101. data/lib/insika/context/builder.rb +21 -3
  102. data/lib/insika/context/fragment.rb +7 -3
  103. data/lib/insika/context/priority.rb +5 -0
  104. data/lib/insika/context/provider.rb +17 -3
  105. data/lib/insika/context/providers/briefing.rb +96 -0
  106. data/lib/insika/context/providers/knowledge.rb +108 -0
  107. data/lib/insika/context/providers/memory.rb +16 -7
  108. data/lib/insika/context/providers/prompt.rb +57 -23
  109. data/lib/insika/context/providers/skill.rb +2 -0
  110. data/lib/insika/context/providers/tool_search.rb +2 -0
  111. data/lib/insika/context_trace_store.rb +38 -2
  112. data/lib/insika/cron.rb +189 -0
  113. data/lib/insika/demo/agent_attrs.rb +43 -0
  114. data/lib/insika/demo/golden_cases.rb +81 -0
  115. data/lib/insika/demo/seeder.rb +336 -0
  116. data/lib/insika/distill.rb +224 -0
  117. data/lib/insika/distill_engine.rb +169 -0
  118. data/lib/insika/doctor.rb +889 -11
  119. data/lib/insika/dsl/definition.rb +3 -2
  120. data/lib/insika/dsl/runtime.rb +69 -82
  121. data/lib/insika/dsl/server_boot.rb +92 -1
  122. data/lib/insika/dsl/system.rb +10 -2
  123. data/lib/insika/dsl.rb +186 -5
  124. data/lib/insika/edge_limiter.rb +28 -7
  125. data/lib/insika/env_schema.rb +25 -3
  126. data/lib/insika/errors.rb +11 -0
  127. data/lib/insika/evals/assertions.rb +3 -2
  128. data/lib/insika/evals/golden.rb +41 -4
  129. data/lib/insika/evals/judge.rb +47 -2
  130. data/lib/insika/evals/pairwise.rb +29 -6
  131. data/lib/insika/evals/persona.rb +98 -0
  132. data/lib/insika/evals/runner.rb +9 -0
  133. data/lib/insika/evals/simulator.rb +225 -0
  134. data/lib/insika/evals/transport.rb +83 -1
  135. data/lib/insika/event_stream.rb +10 -0
  136. data/lib/insika/evidence.rb +183 -0
  137. data/lib/insika/executor.rb +869 -76
  138. data/lib/insika/followup_engine.rb +207 -0
  139. data/lib/insika/followup_policy.rb +198 -0
  140. data/lib/insika/followup_store.rb +306 -0
  141. data/lib/insika/funnel_declaration.rb +106 -0
  142. data/lib/insika/funnel_fold.rb +179 -0
  143. data/lib/insika/funnel_store.rb +163 -0
  144. data/lib/insika/golden_store.rb +17 -2
  145. data/lib/insika/grounding/matcher.rb +69 -0
  146. data/lib/insika/grounding.rb +44 -0
  147. data/lib/insika/harvest/conversion_gate.rb +159 -0
  148. data/lib/insika/harvest/criterion.rb +98 -0
  149. data/lib/insika/harvest/gate.rb +194 -0
  150. data/lib/insika/harvest/negative_list.rb +199 -0
  151. data/lib/insika/harvest.rb +241 -0
  152. data/lib/insika/harvest_engine.rb +193 -0
  153. data/lib/insika/harvest_store.rb +548 -0
  154. data/lib/insika/knowledge.rb +680 -0
  155. data/lib/insika/knowledge_store.rb +140 -0
  156. data/lib/insika/mcp_client.rb +94 -0
  157. data/lib/insika/mcp_json.rb +74 -0
  158. data/lib/insika/mcp_live_tool.rb +43 -0
  159. data/lib/insika/mcp_store.rb +98 -26
  160. data/lib/insika/mcp_tool_ingestor.rb +30 -8
  161. data/lib/insika/mcp_tool_registry.rb +100 -0
  162. data/lib/insika/media.rb +382 -0
  163. data/lib/insika/memory_audit_store.rb +85 -0
  164. data/lib/insika/memory_store.rb +264 -23
  165. data/lib/insika/message_origin.rb +7 -2
  166. data/lib/insika/middleware.rb +9 -0
  167. data/lib/insika/model_visible.rb +87 -0
  168. data/lib/insika/model_visible_trace_store.rb +66 -0
  169. data/lib/insika/onboarding.rb +22 -1
  170. data/lib/insika/outbox_store.rb +42 -4
  171. data/lib/insika/outcome_store.rb +147 -0
  172. data/lib/insika/overlay_tool_registry.rb +37 -17
  173. data/lib/insika/packaging.rb +163 -0
  174. data/lib/insika/parity/criterion.rb +79 -0
  175. data/lib/insika/parity/verdict.rb +318 -0
  176. data/lib/insika/prefix_fingerprint.rb +58 -0
  177. data/lib/insika/profile_source.rb +29 -1
  178. data/lib/insika/prompt_catalog.rb +10 -0
  179. data/lib/insika/proposal_store.rb +271 -0
  180. data/lib/insika/queue_policy.rb +4 -1
  181. data/lib/insika/refinement/proposer.rb +1 -1
  182. data/lib/insika/reliability.rb +32 -6
  183. data/lib/insika/retention.rb +316 -0
  184. data/lib/insika/router/app.rb +157 -0
  185. data/lib/insika/router/backend_pool.rb +98 -0
  186. data/lib/insika/router/hash_ring.rb +55 -0
  187. data/lib/insika/router/proxy_body.rb +34 -0
  188. data/lib/insika/router/session_key.rb +54 -0
  189. data/lib/insika/router.rb +18 -0
  190. data/lib/insika/routing.rb +101 -0
  191. data/lib/insika/safety/config.rb +44 -4
  192. data/lib/insika/safety/corpus.rb +255 -0
  193. data/lib/insika/safety/detectors.rb +32 -113
  194. data/lib/insika/safety/factory.rb +15 -2
  195. data/lib/insika/safety/grounding_enforcer.rb +59 -0
  196. data/lib/insika/safety/grounding_validator.rb +49 -0
  197. data/lib/insika/safety/input_guardrail.rb +1 -1
  198. data/lib/insika/safety/moderator.rb +1 -1
  199. data/lib/insika/safety/output_filter.rb +9 -5
  200. data/lib/insika/safety/output_validator.rb +11 -5
  201. data/lib/insika/schedule.rb +177 -0
  202. data/lib/insika/schedule_engine.rb +314 -0
  203. data/lib/insika/schedule_store.rb +208 -0
  204. data/lib/insika/schema_guard.rb +35 -0
  205. data/lib/insika/server/app.rb +247 -17
  206. data/lib/insika/server/rack_app.rb +21 -1
  207. data/lib/insika/server/responses.rb +40 -1
  208. data/lib/insika/session_actor.rb +8 -4
  209. data/lib/insika/session_store.rb +65 -2
  210. data/lib/insika/settings_store.rb +10 -0
  211. data/lib/insika/shadow_pair_store.rb +258 -0
  212. data/lib/insika/skill_catalog.rb +16 -0
  213. data/lib/insika/soak/envelope.rb +140 -0
  214. data/lib/insika/soak/report.rb +392 -0
  215. data/lib/insika/soak/runner.rb +554 -0
  216. data/lib/insika/steer_injector.rb +21 -10
  217. data/lib/insika/store.rb +10 -1
  218. data/lib/insika/stores/memory.rb +6 -0
  219. data/lib/insika/stores/sqlite.rb +8 -0
  220. data/lib/insika/studio/app.rb +1437 -54
  221. data/lib/insika/studio/assets/dist/application.css +1 -1
  222. data/lib/insika/studio/assets/dist/application.js +23 -23
  223. data/lib/insika/studio/assets/dist/favicon.svg +6 -0
  224. data/lib/insika/studio/forms.rb +308 -10
  225. data/lib/insika/studio/nav_icons.rb +19 -1
  226. data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
  227. data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
  228. data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
  229. data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
  230. data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
  231. data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
  232. data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
  233. data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
  234. data/lib/insika/studio/views/_agents_master.erb +44 -0
  235. data/lib/insika/studio/views/_message.erb +49 -32
  236. data/lib/insika/studio/views/agent_detail.erb +62 -278
  237. data/lib/insika/studio/views/agents.erb +71 -54
  238. data/lib/insika/studio/views/approvals.erb +4 -1
  239. data/lib/insika/studio/views/artifact.erb +23 -0
  240. data/lib/insika/studio/views/artifacts.erb +59 -0
  241. data/lib/insika/studio/views/chats.erb +4 -1
  242. data/lib/insika/studio/views/customer.erb +94 -0
  243. data/lib/insika/studio/views/customers.erb +32 -0
  244. data/lib/insika/studio/views/evals.erb +6 -3
  245. data/lib/insika/studio/views/facts.erb +133 -0
  246. data/lib/insika/studio/views/followups.erb +125 -0
  247. data/lib/insika/studio/views/funnel.erb +106 -0
  248. data/lib/insika/studio/views/harvest.erb +234 -0
  249. data/lib/insika/studio/views/home.erb +106 -66
  250. data/lib/insika/studio/views/knowledge.erb +123 -0
  251. data/lib/insika/studio/views/layout.erb +15 -11
  252. data/lib/insika/studio/views/mcp.erb +174 -80
  253. data/lib/insika/studio/views/parity.erb +147 -0
  254. data/lib/insika/studio/views/playground.erb +7 -1
  255. data/lib/insika/studio/views/session.erb +233 -124
  256. data/lib/insika/studio/views/settings.erb +41 -1
  257. data/lib/insika/studio/views/skills.erb +1 -2
  258. data/lib/insika/studio/views/system_files.erb +1 -1
  259. data/lib/insika/studio/views/task.erb +13 -0
  260. data/lib/insika/studio/views/tasks.erb +4 -1
  261. data/lib/insika/studio/views/tools.erb +24 -10
  262. data/lib/insika/task_store.rb +21 -1
  263. data/lib/insika/templates/browser-agent/README.md +36 -0
  264. data/lib/insika/templates/browser-agent/agent.rb +49 -0
  265. data/lib/insika/templates/daily-digest/README.md +38 -0
  266. data/lib/insika/templates/daily-digest/agent.rb +77 -0
  267. data/lib/insika/templates/repo-explorer/README.md +36 -0
  268. data/lib/insika/templates/repo-explorer/agent.rb +45 -0
  269. data/lib/insika/templates/research-analyst/README.md +26 -0
  270. data/lib/insika/templates/research-analyst/agent.rb +58 -0
  271. data/lib/insika/templates/review-panel/README.md +20 -0
  272. data/lib/insika/templates/review-panel/agent.rb +50 -0
  273. data/lib/insika/templates/travel-planner/README.md +35 -0
  274. data/lib/insika/templates/travel-planner/agent.rb +87 -0
  275. data/lib/insika/templates.rb +112 -0
  276. data/lib/insika/testing/store_contract.rb +27 -6
  277. data/lib/insika/tick.rb +47 -11
  278. data/lib/insika/timezone.rb +45 -0
  279. data/lib/insika/tool_definition.rb +17 -7
  280. data/lib/insika/tool_envelope.rb +69 -0
  281. data/lib/insika/tool_manifest.rb +5 -1
  282. data/lib/insika/tools/data_defined_tool.rb +10 -0
  283. data/lib/insika/tools/generate_image.rb +89 -0
  284. data/lib/insika/tools/load_knowledge.rb +74 -0
  285. data/lib/insika/tools/run_persona_eval.rb +328 -0
  286. data/lib/insika/tools/save_artifact.rb +95 -0
  287. data/lib/insika/tools/schedule_followup.rb +164 -0
  288. data/lib/insika/tools/tts.rb +47 -0
  289. data/lib/insika/tools/update_briefing.rb +126 -0
  290. data/lib/insika/turn_output.rb +1 -1
  291. data/lib/insika/turn_state.rb +49 -1
  292. data/lib/insika/turn_timing.rb +22 -2
  293. data/lib/insika/version.rb +1 -1
  294. data/lib/insika/vitals.rb +84 -0
  295. data/lib/insika/wiring/graph.rb +472 -13
  296. data/lib/insika/wiring/graph_chat.rb +102 -0
  297. data/lib/insika.rb +149 -3
  298. metadata +181 -5
  299. data/docs/build.md +0 -14
  300. data/docs/understand.md +0 -10
@@ -0,0 +1,680 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Insika
7
+ # the one place knowledge extraction asks a model for anything, and the
8
+ # concept format itself (markdown + YAML frontmatter, the same shape a
9
+ # SKILL.md uses).
10
+ #
11
+ # The engine's generic prompt (what a concept worth keeping is, the answer
12
+ # shape, the "never invent, never state as policy" rules). A pack
13
+ # `knowledge.prompt` REPLACES this wholesale (the forge's half), like
14
+ # `distill.prompt` / `harvest.prompt`.
15
+ module Knowledge
16
+ DEFAULT_TYPES = %w[fact entity procedure policy objection].freeze
17
+
18
+ DEFAULT_PROMPT = <<~PROMPT.freeze
19
+ You are extracting durable KNOWLEDGE from one finished conversation, for
20
+ a store agent's shared memory. A concept is something worth remembering
21
+ across DIFFERENT conversations: a policy, a recurring question, an
22
+ objection customers raise, a fact about how the business operates. It
23
+ is NOT a fact about one customer (that belongs to per-customer memory)
24
+ and NOT a summary of this conversation.
25
+
26
+ Answer with a single JSON array and NOTHING else. No prose, no fences.
27
+
28
+ Each element is an object with:
29
+ - "name" — a short, lowercase, hyphen-separated slug (max 80 chars);
30
+ - "description" — one line (max 300 chars): what the concept says;
31
+ - "type" — one of: fact, entity, procedure, policy, objection;
32
+ - "body" — the concept's content (max 2000 chars): the durable claim,
33
+ in your own words, plus `[[other-concept-name]]` links to any related
34
+ concept you are also proposing in this same answer.
35
+
36
+ Rules:
37
+ - Never invent: only concepts the conversation actually supports.
38
+ - Never state something the agent merely PROMISED as if it were
39
+ official policy — describe it as what was said, not as a guarantee.
40
+ - Never include a customer id, a session id or anything that identifies
41
+ one person — the engine stamps provenance itself.
42
+ - A concept every good agent already assumes is not worth proposing.
43
+ - Fewer, better concepts beat filling a quota.
44
+ PROMPT
45
+
46
+ # The safe-subset JSON Schema (Workflow::Schema, the house zero-dep
47
+ # validator). Anything outside this set — a model-authored `provenance`,
48
+ # `confidence`, `sources`, `occurrences` — is a provenance lie: the schema
49
+ # refuses it by not having the key, and the extractor drops+counts it
50
+ # rather than trusting the model's self-assessment (RFC's "provenance
51
+ # only as ids" rule, enforced here, not just documented).
52
+ CONCEPT_SCHEMA = Insika::Workflow::Schema.coerce({
53
+ "type" => "array",
54
+ "items" => {
55
+ "type" => "object",
56
+ "properties" => {
57
+ "name" => { "type" => "string" },
58
+ "description" => { "type" => "string" },
59
+ "type" => { "type" => "string" },
60
+ "body" => { "type" => "string" }
61
+ },
62
+ "required" => %w[name description type body]
63
+ }
64
+ })
65
+
66
+ ITEM_SCHEMA = Insika::Workflow::Schema.coerce(CONCEPT_SCHEMA.json_schema["items"])
67
+
68
+ module_function
69
+
70
+ # The layer-2 confidence formula: more independent sightings, more
71
+ # confidence, never certainty. `confidence_for(1)` is the first-sighting
72
+ # value PR 1 hardcoded (0.6) — spelled out here as the formula's own
73
+ # degenerate case, not a separate constant to keep in sync.
74
+ CONFIDENCE_BASE = 0.5
75
+ CONFIDENCE_STEP = 0.1
76
+ CONFIDENCE_CAP = 0.95
77
+ def confidence_for(distinct_sources) = [CONFIDENCE_CAP, CONFIDENCE_BASE + (CONFIDENCE_STEP * distinct_sources)].min
78
+
79
+ # A contradiction is never silently resolved into a confidence climb —
80
+ # it is a flat drop, regardless of how confirmed the concept was before.
81
+ CONTRADICTION_CONFIDENCE = 0.4
82
+ CONTRADICTION_HEADING = "## Contradiction"
83
+
84
+ # The ONE entry point every write path uses (the Executor's terminal
85
+ # hook, the backfill CLI, and a Studio-authored concept). Decides
86
+ # new/same/related/contradicting when `concept:<name>` already exists,
87
+ # and stamps the result — the model never gets to (RFC's "provenance
88
+ # only as ids" rule). The ONE place a concept's body is redacted (every
89
+ # write goes through this, so a caller cannot forget).
90
+ #
91
+ # store: KnowledgeStore.
92
+ # concept: {"name","description","type","body"} — the extractor's
93
+ # candidate; body not yet redacted.
94
+ # consolidator: Consolidator | nil. nil = the conservative default: a
95
+ # differing body is always `:contradicting` (never
96
+ # silently overwritten) rather than spending a model call
97
+ # to guess.
98
+ # -> { verdict: :new | :same | :related | :contradicting, name:, type: }
99
+ def write_concept(store:, agent_id:, concept:, session_id:, tenant: nil, consolidator: nil)
100
+ name = concept["name"].to_s
101
+ redacted_body, = Insika::Safety::Detectors.redact(concept["body"].to_s)
102
+ current = store.get(agent_id, name, tenant: tenant)
103
+
104
+ if current.nil?
105
+ store.write(agent_id, name, first_sighting(concept, redacted_body, session_id), tenant: tenant)
106
+ return { verdict: :new, name: name, type: concept["type"].to_s }
107
+ end
108
+
109
+ existing = Concept.parse(current)
110
+ if same_claim?(existing[:body], redacted_body)
111
+ store.write(agent_id, name, bump(existing, session_id), tenant: tenant)
112
+ return { verdict: :same, name: existing[:name], type: existing[:type] }
113
+ end
114
+
115
+ resolution = consolidator && consolidator.resolve(existing_body: existing[:body], new_body: redacted_body)
116
+ if resolution && resolution[:verdict] == :related
117
+ store.write(agent_id, name, merge(existing, resolution[:merged_body], session_id), tenant: tenant)
118
+ { verdict: :related, name: existing[:name], type: existing[:type] }
119
+ else
120
+ store.write(agent_id, name, contradict(existing, redacted_body, session_id), tenant: tenant)
121
+ { verdict: :contradicting, name: existing[:name], type: existing[:type] }
122
+ end
123
+ end
124
+
125
+ def first_sighting(concept, redacted_body, session_id)
126
+ now = Time.now.utc.iso8601
127
+ Concept.render(
128
+ name: concept["name"], description: concept["description"], type: concept["type"], body: redacted_body,
129
+ provenance: "observed", confidence: confidence_for(1), sources: [session_id.to_s], occurrences: 1,
130
+ created_at: now, updated_at: now
131
+ )
132
+ end
133
+
134
+ # Same claim, reworded or reconfirmed: the body stays (no operator edit
135
+ # is silently discarded by a repeat sighting), only the evidence grows.
136
+ def bump(existing, session_id)
137
+ sources = (existing[:sources] + [session_id.to_s]).uniq
138
+ Concept.render(
139
+ name: existing[:name], description: existing[:description], type: existing[:type], body: existing[:body],
140
+ provenance: "observed", confidence: confidence_for(sources.size), sources: sources,
141
+ occurrences: existing[:occurrences] + 1, created_at: existing[:created_at], updated_at: Time.now.utc.iso8601
142
+ )
143
+ end
144
+
145
+ # Related claim: the consolidator's merged text replaces the body — the
146
+ # only branch where a second model call decided the wording.
147
+ def merge(existing, merged_body, session_id)
148
+ sources = (existing[:sources] + [session_id.to_s]).uniq
149
+ Concept.render(
150
+ name: existing[:name], description: existing[:description], type: existing[:type], body: merged_body,
151
+ provenance: "observed", confidence: confidence_for(sources.size), sources: sources,
152
+ occurrences: existing[:occurrences] + 1, created_at: existing[:created_at], updated_at: Time.now.utc.iso8601
153
+ )
154
+ end
155
+
156
+ # Contradicting claim: never overwritten. The new claim joins the body
157
+ # under a heading a human resolves in the Studio; occurrences do NOT
158
+ # bump (a conflict is not a confirmation), but the sighting still joins
159
+ # `sources` for the audit trail.
160
+ def contradict(existing, new_body, session_id)
161
+ sources = (existing[:sources] + [session_id.to_s]).uniq
162
+ body = "#{existing[:body]}\n\n#{CONTRADICTION_HEADING}\n\n#{new_body}"
163
+ Concept.render(
164
+ name: existing[:name], description: existing[:description], type: existing[:type], body: body,
165
+ provenance: "observed", confidence: CONTRADICTION_CONFIDENCE, sources: sources,
166
+ occurrences: existing[:occurrences], created_at: existing[:created_at], updated_at: Time.now.utc.iso8601
167
+ )
168
+ end
169
+
170
+ # Normalized equality (strip/downcase/collapse whitespace) — cheap and
171
+ # deterministic, no model call spent confirming a reworded repeat.
172
+ def same_claim?(a, b) = normalize_claim(a) == normalize_claim(b)
173
+ def normalize_claim(text) = text.to_s.strip.downcase.gsub(/\s+/, " ")
174
+
175
+ # The concept format: markdown with a YAML frontmatter block, parsed and
176
+ # rendered with the SAME split `SkillCatalog#parse_content` uses, so the
177
+ # Studio's existing markdown editor already curates it.
178
+ module Concept
179
+ module_function
180
+
181
+ NAME_RE = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
182
+
183
+ # -> { name:, description:, type:, body:, provenance:, confidence:,
184
+ # sources:, occurrences:, created_at:, updated_at: } | nil
185
+ def parse(raw)
186
+ match = raw.to_s.match(/\A---\s*\n(.*?)\n---\s*\n(.*)\z/m)
187
+ return nil unless match
188
+
189
+ meta = Insika::Frontmatter.parse(match[1])
190
+ name = Coercion.presence(meta["name"])
191
+ return nil unless name
192
+
193
+ {
194
+ name: name.to_s, description: meta["description"].to_s, type: meta["type"].to_s,
195
+ provenance: meta["provenance"].to_s, confidence: meta["confidence"].to_f,
196
+ sources: Array(meta["sources"]).map(&:to_s), occurrences: meta["occurrences"].to_i,
197
+ created_at: meta["created_at"].to_s, updated_at: meta["updated_at"].to_s,
198
+ body: match[2].strip
199
+ }
200
+ end
201
+
202
+ # Renders the complete concept markdown. Every field but `body` is
203
+ # engine-stamped (never the model's own words) — see `Extractor`.
204
+ def render(name:, description:, type:, body:, provenance:, confidence:, sources:, occurrences:,
205
+ created_at:, updated_at:)
206
+ frontmatter = {
207
+ "name" => name, "description" => description, "type" => type,
208
+ "provenance" => provenance, "confidence" => confidence.round(2),
209
+ "sources" => Array(sources), "occurrences" => occurrences,
210
+ "created_at" => created_at, "updated_at" => updated_at
211
+ }
212
+ yaml = frontmatter.map { |k, v| "#{k}: #{v.to_json}" }.join("\n")
213
+ "---\n#{yaml}\n---\n\n#{body}\n"
214
+ end
215
+
216
+ # `[[name]]` references inside a concept's body — the whole "graph"
217
+ # layers 1+2 support: plain text, resolved lazily by name, never a
218
+ # stored structure of its own. Shared by retrieval's one-hop expansion
219
+ # and the GraphML export, so the two readings of "a link" never drift.
220
+ def links(body)
221
+ body.to_s.scan(/\[\[([^\]]+)\]\]/).flatten.map(&:strip).reject(&:empty?).uniq
222
+ end
223
+ end
224
+
225
+ # The model's raw concepts, filtered into data the caller stamps with
226
+ # provenance and persists. Pure over an injected `ask` — the
227
+ # Distiller/Miner shape: unit-testable without a provider.
228
+ class Extractor
229
+ # A model that answers with prose instead of JSON produces NOTHING,
230
+ # loudly — empty output must not read as "the turn taught nothing".
231
+ class Unusable < Insika::ValidationError; end
232
+
233
+ MAX_NAME = 80
234
+ MAX_DESCRIPTION = 300
235
+ MAX_BODY = 2000
236
+ ALLOWED_KEYS = %w[name description type body].freeze
237
+ # The audit counters: schema (shape/type miss), unknown_key (a
238
+ # model-authored provenance/confidence/sources — the escape this
239
+ # extractor exists to block), bad_type (outside the configured
240
+ # allowlist), oversized (length caps), duplicate (exact repeats within
241
+ # the batch), capped (a survivor over max_concepts).
242
+ DROP_KEYS = %w[schema unknown_key bad_type oversized duplicate capped].freeze
243
+
244
+ attr_reader :model, :types
245
+
246
+ # ask: ->(prompt) { "<raw model text>" } | something answering #content
247
+ # (+ #input_tokens/#output_tokens/#cached_tokens for cost).
248
+ # model: the ref recorded on the concept's provenance ("utility_model"
249
+ # default).
250
+ # types: the allowed concept types (the profile's `knowledge.types`,
251
+ # default DEFAULT_TYPES) — a type outside this set is dropped,
252
+ # never silently reclassified.
253
+ def initialize(ask:, model: "utility_model", types: DEFAULT_TYPES)
254
+ @ask = ask
255
+ @model = model.to_s
256
+ @types = Array(types).map(&:to_s)
257
+ end
258
+
259
+ # -> { concepts: [{ "name", "description", "type", "body" }],
260
+ # dropped: { "schema" => N, "unknown_key" => N, "bad_type" => N,
261
+ # "oversized" => N, "duplicate" => N, "capped" => N },
262
+ # cost: { "spent" => N, "cached" => N } | nil }
263
+ def extract(prompt:, max_concepts: 10)
264
+ answer = @ask.call(prompt)
265
+ raw = parse(text_of(answer))
266
+ concepts = []
267
+ dropped = DROP_KEYS.to_h { |k| [k, 0] }
268
+ seen = {}
269
+ raw.each do |item|
270
+ verdict, reason = classify(item)
271
+ case verdict
272
+ when :keep
273
+ tuple = [item["name"].to_s, item["body"].to_s]
274
+ if seen[tuple]
275
+ dropped["duplicate"] += 1
276
+ elsif concepts.size >= max_concepts
277
+ dropped["capped"] += 1
278
+ else
279
+ seen[tuple] = true
280
+ concepts << normalize(item)
281
+ end
282
+ when :drop
283
+ dropped[reason] += 1
284
+ end
285
+ end
286
+ { concepts: concepts, dropped: dropped, cost: cost_of(answer) }
287
+ end
288
+
289
+ private
290
+
291
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
292
+
293
+ def cost_of(answer)
294
+ return nil unless answer.respond_to?(:input_tokens) && answer.respond_to?(:output_tokens)
295
+
296
+ input = answer.input_tokens.to_i
297
+ output = answer.output_tokens.to_i
298
+ cached = answer.respond_to?(:cached_tokens) ? answer.cached_tokens.to_i : 0
299
+ spent = input + output + cached
300
+ spent.positive? ? { "spent" => spent, "cached" => cached } : nil
301
+ end
302
+
303
+ # Fences stripped, parsed STRICTLY: a model that improvises a schema
304
+ # fails here instead of producing half a concept, never persisted
305
+ # half-parsed.
306
+ def parse(raw)
307
+ body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
308
+ parsed = JSON.parse(body)
309
+ raise Unusable, "the extractor's answer is not an array" unless parsed.is_a?(Array)
310
+
311
+ parsed
312
+ rescue JSON::ParserError => e
313
+ raise Unusable, "the extractor's answer is not valid JSON: #{e.message}"
314
+ end
315
+
316
+ # -> [:keep, nil] | [:drop, drop_key]. Unknown keys first (a
317
+ # model-authored provenance/confidence/sources is the escape this
318
+ # extractor exists to block), then the schema, then the type allowlist,
319
+ # then the length caps.
320
+ def classify(item)
321
+ return [:drop, "schema"] unless item.is_a?(Hash)
322
+ return [:drop, "unknown_key"] unless (item.keys.map(&:to_s) - ALLOWED_KEYS).empty?
323
+ return [:drop, "schema"] unless ITEM_SCHEMA.call(item).success?
324
+ return [:drop, "bad_type"] unless @types.include?(item["type"].to_s)
325
+
326
+ name = item["name"].to_s
327
+ return [:drop, "schema"] unless Concept::NAME_RE.match?(name)
328
+ return [:drop, "oversized"] if oversized?(item)
329
+
330
+ [:keep, nil]
331
+ end
332
+
333
+ def oversized?(item)
334
+ item["name"].to_s.length > MAX_NAME ||
335
+ item["description"].to_s.length > MAX_DESCRIPTION ||
336
+ item["body"].to_s.length > MAX_BODY
337
+ end
338
+
339
+ # The SAFE subset only — anything the model smuggled in has already
340
+ # dropped the concept.
341
+ def normalize(item) = item.slice(*ALLOWED_KEYS)
342
+ end
343
+
344
+ # Resolves WHICH model extracts, and builds the ask. Profile -> platform
345
+ # utility_model -> nil (nil means "feature inert", never a guess).
346
+ # `ask_factory`/`llm` injectable (specs).
347
+ module ExtractorFactory
348
+ module_function
349
+
350
+ # config: the agent's `knowledge` hash. -> Extractor | nil
351
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
352
+ ref = Coercion.presence(config && config["model"]) || Coercion.presence(utility_model)
353
+ return nil if ref.nil?
354
+
355
+ provider, model = split_ref(ref)
356
+ types = Array(config && config["types"]).map(&:to_s)
357
+ types = DEFAULT_TYPES if types.empty?
358
+ factory = ask_factory || ->(m, p) { ruby_llm_ask(m, p, llm: llm) }
359
+ Extractor.new(ask: factory.call(model, provider), model: ref, types: types)
360
+ end
361
+
362
+ # "provider/model" -> [provider, model]; "model" -> [nil, model].
363
+ def split_ref(ref)
364
+ prov, name = ref.to_s.split("/", 2)
365
+ name ? [prov, name] : [nil, prov]
366
+ end
367
+
368
+ # Temperature 0: extraction should be deterministic for the same turn.
369
+ # `ruby_llm` required lazily so nothing loads a provider gem until an
370
+ # extractor is actually configured (load_guard stays green).
371
+ def ruby_llm_ask(model, provider, llm: nil)
372
+ require "ruby_llm"
373
+ llm ||= RubyLLM
374
+ lambda do |prompt|
375
+ llm.chat(model: model, provider: provider, assume_model_exists: true)
376
+ .with_temperature(0).ask(prompt)
377
+ end
378
+ end
379
+ end
380
+
381
+ # The "second LLM call" §3.5 describes — spent ONLY when a concept name
382
+ # already exists AND the new sighting's body differs from the stored one
383
+ # (the cheap same-claim check in `Knowledge.write_concept` already
384
+ # handled the identical-reworded case without a call). ONE call decides
385
+ # AND merges together: `Workflow::Schema` cannot express "merged_body
386
+ # required only if verdict==related", so the schema only requires
387
+ # `verdict` and a Ruby check afterward treats a missing/oversized
388
+ # `merged_body` on a "related" answer as unusable.
389
+ #
390
+ # `resolve` never raises: any parse/schema failure — or simply not being
391
+ # configured — resolves to `{verdict: :contradicting}`, the safe default
392
+ # when the engine cannot tell. A guess that risks merging two genuinely
393
+ # different claims into one confident-sounding lie is the worse failure
394
+ # mode; a conflict a human has to look at is not.
395
+ class Consolidator
396
+ class Unusable < Insika::ValidationError; end
397
+
398
+ DEFAULT_PROMPT = <<~PROMPT.freeze
399
+ You are comparing two claims about the SAME concept in a store's
400
+ knowledge base — an existing one already on record, and a new one
401
+ just observed in a conversation.
402
+
403
+ Answer with a single JSON object and NOTHING else. No prose, no fences.
404
+
405
+ - If the two claims are COMPATIBLE — they can be combined into one
406
+ coherent statement without losing or inventing anything either one
407
+ said — answer {"verdict": "related", "merged_body": "<the merged
408
+ claim, in your own words, max 2000 chars>"}.
409
+ - If they state genuinely DIFFERENT things and merging would hide a
410
+ real change or disagreement, answer {"verdict": "contradicting"}.
411
+
412
+ When unsure, prefer "contradicting" — a human resolves it; a wrong
413
+ merge would state something nobody actually confirmed.
414
+ PROMPT
415
+
416
+ VERDICT_SCHEMA = Insika::Workflow::Schema.coerce({
417
+ "type" => "object",
418
+ "properties" => {
419
+ "verdict" => { "type" => "string" },
420
+ "merged_body" => { "type" => "string" }
421
+ },
422
+ "required" => ["verdict"]
423
+ })
424
+
425
+ MAX_MERGED_BODY = 2000
426
+
427
+ attr_reader :model
428
+
429
+ def initialize(ask:, model: "utility_model")
430
+ @ask = ask
431
+ @model = model.to_s
432
+ end
433
+
434
+ # -> { verdict: :related, merged_body: String } | { verdict: :contradicting }
435
+ def resolve(existing_body:, new_body:)
436
+ answer = @ask.call(prompt_for(existing_body, new_body))
437
+ parsed = parse(text_of(answer))
438
+ raise Unusable, "not a JSON object" unless VERDICT_SCHEMA.call(parsed).success?
439
+
440
+ classify(parsed)
441
+ rescue Unusable
442
+ { verdict: :contradicting }
443
+ end
444
+
445
+ private
446
+
447
+ def classify(parsed)
448
+ return { verdict: :contradicting } unless parsed["verdict"].to_s == "related"
449
+
450
+ merged = parsed["merged_body"].to_s
451
+ return { verdict: :contradicting } if merged.strip.empty? || merged.length > MAX_MERGED_BODY
452
+
453
+ { verdict: :related, merged_body: merged }
454
+ end
455
+
456
+ def text_of(answer) = (answer.respond_to?(:content) ? answer.content : answer).to_s
457
+
458
+ def parse(raw)
459
+ body = raw.strip.gsub(/\A```(?:json)?\s*|\s*```\z/, "")
460
+ parsed = JSON.parse(body)
461
+ raise Unusable, "the consolidator's answer is not an object" unless parsed.is_a?(Hash)
462
+
463
+ parsed
464
+ rescue JSON::ParserError
465
+ raise Unusable, "the consolidator's answer is not valid JSON"
466
+ end
467
+
468
+ def prompt_for(existing_body, new_body)
469
+ <<~PROMPT
470
+ #{DEFAULT_PROMPT}
471
+
472
+ ## Existing claim
473
+
474
+ #{existing_body}
475
+
476
+ ## New claim
477
+
478
+ #{new_body}
479
+ PROMPT
480
+ end
481
+ end
482
+
483
+ # Resolves WHICH model consolidates — the same slot the extractor uses
484
+ # (a deployment names one `knowledge.model`, not two). Reuses
485
+ # `ExtractorFactory`'s ref-parsing and lazy ruby_llm ask builder rather
486
+ # than duplicating them (both live in this same module).
487
+ module ConsolidatorFactory
488
+ module_function
489
+
490
+ def build(config, utility_model: nil, ask_factory: nil, llm: nil)
491
+ ref = Coercion.presence(config && config["model"]) || Coercion.presence(utility_model)
492
+ return nil if ref.nil?
493
+
494
+ provider, model = ExtractorFactory.split_ref(ref)
495
+ factory = ask_factory || ->(m, p) { ExtractorFactory.ruby_llm_ask(m, p, llm: llm) }
496
+ Consolidator.new(ask: factory.call(model, provider), model: ref)
497
+ end
498
+ end
499
+
500
+ # Retrieval's index: a PORT with two adapters, selected by config —
501
+ # the same "config over code" shape `Sandbox.provider_for` uses. Unlike
502
+ # Sandbox (a security boundary, so an unknown value is a loud error),
503
+ # an unrecognized or not-yet-built index name degrades to `Scan` rather
504
+ # than failing the turn: retrieval is a quality feature, never a boot
505
+ # blocker (RFC §3.7's own described FTS5-absent fallback).
506
+ module Index
507
+ module_function
508
+
509
+ # config: the agent's `knowledge` hash. -> an object responding to
510
+ # #search(agent_id, tenant:, query:, top_k:).
511
+ def build(config, store:)
512
+ case (config && config["index"]).to_s
513
+ when "fts5" then Scan.new(store: store) # PR 4 — not built yet, same fallback
514
+ else Scan.new(store: store)
515
+ end
516
+ end
517
+
518
+ # Pure Ruby term-overlap search over one agent's concepts — no SQL, no
519
+ # embeddings. Mirrors `ToolCatalog#search`'s tokenizer/scoring shape
520
+ # (case-insensitive substring match, name weighted over description),
521
+ # extended per RFC §3.7 with a body tier and a confidence × recency
522
+ # multiplier. Correct on every backend; at the scale that matters (a
523
+ # few hundred concepts per agent) this is sub-millisecond.
524
+ class Scan
525
+ NAME_WEIGHT = 3
526
+ DESCRIPTION_WEIGHT = 2
527
+ BODY_WEIGHT = 1
528
+ RECENCY_HALF_LIFE_DAYS = 30.0
529
+
530
+ def initialize(store:)
531
+ @store = store
532
+ # Read cache: parsing a concept's YAML frontmatter dominates search
533
+ # cost (measured: ~90% of it, not the store I/O) — re-parsing it on
534
+ # every search for a concept nothing wrote to since the last read
535
+ # is pure waste. Keyed by (agent, tenant, name); a cached entry is
536
+ # valid only while `updated_at` (the record's own timestamp, read
537
+ # WITHOUT parsing — KnowledgeStore#meta) still matches, so a write
538
+ # invalidates itself for free. One instance is meant to survive
539
+ # across turns (the context provider holds it), fibers included:
540
+ # a plain Hash is safe here the same way a closure-local counter is
541
+ # elsewhere in the engine — MRI fibers do not preempt mid-statement.
542
+ @cache = {}
543
+ end
544
+
545
+ # -> [{name:, description:, type:, confidence:, provenance:, sources:,
546
+ # occurrences:, body:}, ...] sorted by score desc, ties by store
547
+ # enumeration order. Excludes zero-overlap concepts entirely.
548
+ def search(agent_id, query:, tenant: nil, top_k: 5)
549
+ terms = tokenize(query)
550
+ return [] if terms.empty?
551
+
552
+ candidates = @store.names(agent_id, tenant: tenant).filter_map do |name|
553
+ cached_concept(agent_id, name, tenant)
554
+ end
555
+ scored = candidates.each_with_index.filter_map do |concept, idx|
556
+ score = score_of(concept, terms)
557
+ [concept, score, idx] if score.positive?
558
+ end
559
+ scored.sort_by { |_concept, score, idx| [-score, idx] }
560
+ .first(top_k).map(&:first)
561
+ end
562
+
563
+ private
564
+
565
+ def cached_concept(agent_id, name, tenant)
566
+ meta = @store.meta(agent_id, name, tenant: tenant)
567
+ return nil unless meta
568
+
569
+ key = [agent_id, tenant, name]
570
+ hit = @cache[key]
571
+ return hit[:concept] if hit && hit[:updated_at] == meta["updated_at"]
572
+
573
+ concept = Concept.parse(meta["content"])
574
+ @cache[key] = { updated_at: meta["updated_at"], concept: concept }
575
+ concept
576
+ end
577
+
578
+ # Punctuation stripped (a customer's "...Campinas?" must match the
579
+ # concept "campinas") and terms under 3 chars dropped — short
580
+ # function words ("o", "de", "a") substring-match almost anything and
581
+ # would turn every query into a false positive.
582
+ MIN_TERM_LENGTH = 3
583
+
584
+ def tokenize(query)
585
+ query.to_s.downcase.split(/\s+/)
586
+ .map { |t| t.gsub(/[^\p{Alnum}]/, "") }
587
+ .reject { |t| t.length < MIN_TERM_LENGTH }
588
+ end
589
+
590
+ def score_of(concept, terms)
591
+ name = concept[:name].to_s.downcase
592
+ description = concept[:description].to_s.downcase
593
+ body = concept[:body].to_s.downcase
594
+ term_score = terms.sum do |term|
595
+ (name.include?(term) ? NAME_WEIGHT : 0) +
596
+ (description.include?(term) ? DESCRIPTION_WEIGHT : 0) +
597
+ (body.include?(term) ? BODY_WEIGHT : 0)
598
+ end
599
+ return 0 if term_score.zero?
600
+
601
+ term_score * confidence_of(concept) * recency_weight(concept[:updated_at])
602
+ end
603
+
604
+ def confidence_of(concept)
605
+ c = concept[:confidence]
606
+ c.positive? ? c : 0.1 # a zero/blank confidence still ranks, just last among ties
607
+ end
608
+
609
+ # Smooth decay, no hard cutoff: a concept sighted 30 days ago still
610
+ # ranks, just below one confirmed yesterday. `Time.now` is safe here
611
+ # (this is engine runtime code, not a Workflow script).
612
+ def recency_weight(updated_at)
613
+ at = Time.iso8601(updated_at.to_s)
614
+ days = [(Time.now.utc - at) / 86_400.0, 0].max
615
+ 1.0 / (1.0 + (days / RECENCY_HALF_LIFE_DAYS))
616
+ rescue ArgumentError
617
+ 1.0 # unparseable/blank timestamp -> neutral weight, never excluded
618
+ end
619
+ end
620
+ end
621
+
622
+ # The follow-up export shape (§5): one combined graph instead of one
623
+ # file per concept — nodes are concepts (name/type/description/
624
+ # confidence/provenance as node data), edges are `[[links]]` resolved
625
+ # against the SAME export set (a link to a concept outside the scope is
626
+ # dropped, never a dangling edge). Directly consumable by Gephi/yEd/
627
+ # graphify. No new dependency: hand-built and hand-escaped XML, the same
628
+ # zero-dependency discipline the rest of this feature keeps.
629
+ module GraphmlExport
630
+ module_function
631
+
632
+ # concepts: [Concept.parse output, ...]. -> String (one .graphml document).
633
+ def build(concepts)
634
+ names = concepts.map { |c| c[:name] }
635
+ nodes = concepts.map { |c| node(c) }.join("\n")
636
+ edges = concepts.flat_map { |c| edges_for(c, names) }.join("\n")
637
+ <<~XML
638
+ <?xml version="1.0" encoding="UTF-8"?>
639
+ <graphml xmlns="http://graphml.graphdrawing.org/xmlns">
640
+ <key id="name" for="node" attr.name="name" attr.type="string"/>
641
+ <key id="type" for="node" attr.name="type" attr.type="string"/>
642
+ <key id="description" for="node" attr.name="description" attr.type="string"/>
643
+ <key id="confidence" for="node" attr.name="confidence" attr.type="double"/>
644
+ <key id="provenance" for="node" attr.name="provenance" attr.type="string"/>
645
+ <graph id="knowledge" edgedefault="directed">
646
+ #{nodes}
647
+ #{edges}
648
+ </graph>
649
+ </graphml>
650
+ XML
651
+ end
652
+
653
+ def node(concept)
654
+ <<~NODE.chomp
655
+ <node id="#{escape(concept[:name])}">
656
+ <data key="name">#{escape(concept[:name])}</data>
657
+ <data key="type">#{escape(concept[:type])}</data>
658
+ <data key="description">#{escape(concept[:description])}</data>
659
+ <data key="confidence">#{concept[:confidence]}</data>
660
+ <data key="provenance">#{escape(concept[:provenance])}</data>
661
+ </node>
662
+ NODE
663
+ end
664
+
665
+ def edges_for(concept, known_names)
666
+ Concept.links(concept[:body]).select { |name| known_names.include?(name) }.map do |target|
667
+ %( <edge source="#{escape(concept[:name])}" target="#{escape(target)}"/>)
668
+ end
669
+ end
670
+
671
+ # The five XML predefined entities — no library needed for this subset.
672
+ # Order matters: `&` first, or every other substitution's own `&` gets
673
+ # re-escaped.
674
+ def escape(text)
675
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
676
+ .gsub('"', "&quot;").gsub("'", "&apos;")
677
+ end
678
+ end
679
+ end
680
+ end