insika 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +325 -17
- data/README.md +52 -11
- data/bin/insika +895 -8
- data/bin/insika-router +87 -0
- data/docs/AGENTS.md +229 -163
- data/docs/API.md +56 -0
- data/docs/ARCHITECTURE.md +3 -2
- data/docs/ARTIFACTS.md +95 -0
- data/docs/BENCHMARK.md +2 -2
- data/docs/CHANNELS.md +111 -21
- data/docs/CONTEXT.md +132 -19
- data/docs/DEMO.md +80 -0
- data/docs/DEPLOY.md +75 -4
- data/docs/EMBEDDING.md +1 -1
- data/docs/EVALS.md +146 -2
- data/docs/FACTS.md +135 -0
- data/docs/HARVEST.md +116 -0
- data/docs/KNOWLEDGE.md +290 -0
- data/docs/LOADTEST.md +16 -10
- data/docs/MEDIA.md +128 -0
- data/docs/OBSERVABILITY.md +65 -6
- data/docs/OUTCOMES.md +137 -0
- data/docs/PLUGINS.md +51 -6
- data/docs/POLICY.md +216 -0
- data/docs/REFINEMENT.md +17 -12
- data/docs/RELEASING.md +27 -0
- data/docs/ROUTER.md +213 -0
- data/docs/RUNNING-LOCAL.md +6 -6
- data/docs/SCHEDULING.md +121 -0
- data/docs/SECURITY.md +79 -2
- data/docs/SKILLS.md +13 -4
- data/docs/SOAK.md +127 -0
- data/docs/TEMPLATES.md +134 -0
- data/docs/TOOLS.md +220 -27
- data/docs/WHY.md +1 -1
- data/docs/WORKFLOWS.md +2 -2
- data/docs/_includes/head_custom.html +5 -0
- data/docs/_includes/title.html +13 -0
- data/docs/_sass/color_schemes/insika.scss +32 -0
- data/docs/_sass/custom/custom.scss +199 -0
- data/docs/_sass/custom/setup.scss +26 -0
- data/docs/assets/img/favicon.svg +7 -0
- data/docs/assets/img/insika-mark.svg +7 -0
- data/docs/core-concepts.md +21 -0
- data/docs/domain.md +115 -0
- data/docs/improve.md +20 -0
- data/docs/index.md +9 -6
- data/docs/integrate.md +20 -0
- data/docs/operate.md +13 -6
- data/docs/prompts/ADD-TOOL.md +118 -0
- data/docs/prompts/DIAGNOSE-TURN.md +65 -0
- data/docs/prompts/GO-LIVE.md +138 -0
- data/docs/prompts/RUN-EXAMPLES.md +70 -0
- data/docs/reference.md +19 -0
- data/docs/ship.md +10 -2
- data/docs/start-here.md +18 -0
- data/lib/insika/agent_profile.rb +202 -10
- data/lib/insika/artifact_signing.rb +82 -0
- data/lib/insika/artifact_store.rb +160 -0
- data/lib/insika/balloon_splitter.rb +102 -0
- data/lib/insika/budget_ledger.rb +34 -3
- data/lib/insika/cache_series_store.rb +49 -0
- data/lib/insika/channel_delivery.rb +119 -14
- data/lib/insika/channels/relay.rb +77 -3
- data/lib/insika/channels/web.rb +2 -2
- data/lib/insika/chat_builder.rb +105 -2
- data/lib/insika/checkpoint_store.rb +16 -0
- data/lib/insika/commands/agent_payload.rb +3 -3
- data/lib/insika/commands/backfill_knowledge.rb +145 -0
- data/lib/insika/commands/cancel_followup.rb +49 -0
- data/lib/insika/commands/delete_artifact.rb +35 -0
- data/lib/insika/commands/delete_concept.rb +34 -0
- data/lib/insika/commands/delete_mcp.rb +6 -2
- data/lib/insika/commands/delete_tenant_data.rb +107 -0
- data/lib/insika/commands/export_customer_memory.rb +48 -0
- data/lib/insika/commands/forget_customer.rb +117 -0
- data/lib/insika/commands/freeze_funnel_baseline.rb +113 -0
- data/lib/insika/commands/gate_harvest.rb +138 -0
- data/lib/insika/commands/gate_refinement.rb +1 -1
- data/lib/insika/commands/judge_shadow_pairs.rb +124 -0
- data/lib/insika/commands/memory_forget_fact.rb +20 -4
- data/lib/insika/commands/memory_put_fact.rb +23 -4
- data/lib/insika/commands/promote_harvest.rb +130 -0
- data/lib/insika/commands/record_outcome.rb +46 -0
- data/lib/insika/commands/record_shadow_reply.rb +68 -0
- data/lib/insika/commands/refresh_mcp_tools.rb +47 -0
- data/lib/insika/commands/reject_harvest.rb +38 -0
- data/lib/insika/commands/resolve_proposal.rb +108 -0
- data/lib/insika/commands/restore_concept.rb +34 -0
- data/lib/insika/commands/revoke_contact.rb +49 -0
- data/lib/insika/commands/rollback_harvest.rb +86 -0
- data/lib/insika/commands/run_distillation.rb +186 -0
- data/lib/insika/commands/run_harvest.rb +393 -0
- data/lib/insika/commands/seed_demo_data.rb +31 -0
- data/lib/insika/commands/send_message.rb +103 -6
- data/lib/insika/commands/session_purge.rb +67 -0
- data/lib/insika/commands/upsert_mcp.rb +6 -3
- data/lib/insika/commands/write_concept.rb +57 -0
- data/lib/insika/contact_store.rb +183 -0
- data/lib/insika/context/builder.rb +21 -3
- data/lib/insika/context/fragment.rb +7 -3
- data/lib/insika/context/priority.rb +5 -0
- data/lib/insika/context/provider.rb +17 -3
- data/lib/insika/context/providers/briefing.rb +96 -0
- data/lib/insika/context/providers/knowledge.rb +108 -0
- data/lib/insika/context/providers/memory.rb +16 -7
- data/lib/insika/context/providers/prompt.rb +57 -23
- data/lib/insika/context/providers/skill.rb +2 -0
- data/lib/insika/context/providers/tool_search.rb +2 -0
- data/lib/insika/context_trace_store.rb +38 -2
- data/lib/insika/cron.rb +189 -0
- data/lib/insika/demo/agent_attrs.rb +43 -0
- data/lib/insika/demo/golden_cases.rb +81 -0
- data/lib/insika/demo/seeder.rb +336 -0
- data/lib/insika/distill.rb +224 -0
- data/lib/insika/distill_engine.rb +169 -0
- data/lib/insika/doctor.rb +889 -11
- data/lib/insika/dsl/definition.rb +3 -2
- data/lib/insika/dsl/runtime.rb +69 -82
- data/lib/insika/dsl/server_boot.rb +92 -1
- data/lib/insika/dsl/system.rb +10 -2
- data/lib/insika/dsl.rb +186 -5
- data/lib/insika/edge_limiter.rb +28 -7
- data/lib/insika/env_schema.rb +25 -3
- data/lib/insika/errors.rb +11 -0
- data/lib/insika/evals/assertions.rb +3 -2
- data/lib/insika/evals/golden.rb +41 -4
- data/lib/insika/evals/judge.rb +47 -2
- data/lib/insika/evals/pairwise.rb +29 -6
- data/lib/insika/evals/persona.rb +98 -0
- data/lib/insika/evals/runner.rb +9 -0
- data/lib/insika/evals/simulator.rb +225 -0
- data/lib/insika/evals/transport.rb +83 -1
- data/lib/insika/event_stream.rb +10 -0
- data/lib/insika/evidence.rb +183 -0
- data/lib/insika/executor.rb +869 -76
- data/lib/insika/followup_engine.rb +207 -0
- data/lib/insika/followup_policy.rb +198 -0
- data/lib/insika/followup_store.rb +306 -0
- data/lib/insika/funnel_declaration.rb +106 -0
- data/lib/insika/funnel_fold.rb +179 -0
- data/lib/insika/funnel_store.rb +163 -0
- data/lib/insika/golden_store.rb +17 -2
- data/lib/insika/grounding/matcher.rb +69 -0
- data/lib/insika/grounding.rb +44 -0
- data/lib/insika/harvest/conversion_gate.rb +159 -0
- data/lib/insika/harvest/criterion.rb +98 -0
- data/lib/insika/harvest/gate.rb +194 -0
- data/lib/insika/harvest/negative_list.rb +199 -0
- data/lib/insika/harvest.rb +241 -0
- data/lib/insika/harvest_engine.rb +193 -0
- data/lib/insika/harvest_store.rb +548 -0
- data/lib/insika/knowledge.rb +680 -0
- data/lib/insika/knowledge_store.rb +140 -0
- data/lib/insika/mcp_client.rb +94 -0
- data/lib/insika/mcp_json.rb +74 -0
- data/lib/insika/mcp_live_tool.rb +43 -0
- data/lib/insika/mcp_store.rb +98 -26
- data/lib/insika/mcp_tool_ingestor.rb +30 -8
- data/lib/insika/mcp_tool_registry.rb +100 -0
- data/lib/insika/media.rb +382 -0
- data/lib/insika/memory_audit_store.rb +85 -0
- data/lib/insika/memory_store.rb +264 -23
- data/lib/insika/message_origin.rb +7 -2
- data/lib/insika/middleware.rb +9 -0
- data/lib/insika/model_visible.rb +87 -0
- data/lib/insika/model_visible_trace_store.rb +66 -0
- data/lib/insika/onboarding.rb +22 -1
- data/lib/insika/outbox_store.rb +42 -4
- data/lib/insika/outcome_store.rb +147 -0
- data/lib/insika/overlay_tool_registry.rb +37 -17
- data/lib/insika/packaging.rb +163 -0
- data/lib/insika/parity/criterion.rb +79 -0
- data/lib/insika/parity/verdict.rb +318 -0
- data/lib/insika/prefix_fingerprint.rb +58 -0
- data/lib/insika/profile_source.rb +29 -1
- data/lib/insika/prompt_catalog.rb +10 -0
- data/lib/insika/proposal_store.rb +271 -0
- data/lib/insika/queue_policy.rb +4 -1
- data/lib/insika/refinement/proposer.rb +1 -1
- data/lib/insika/reliability.rb +32 -6
- data/lib/insika/retention.rb +316 -0
- data/lib/insika/router/app.rb +157 -0
- data/lib/insika/router/backend_pool.rb +98 -0
- data/lib/insika/router/hash_ring.rb +55 -0
- data/lib/insika/router/proxy_body.rb +34 -0
- data/lib/insika/router/session_key.rb +54 -0
- data/lib/insika/router.rb +18 -0
- data/lib/insika/routing.rb +101 -0
- data/lib/insika/safety/config.rb +44 -4
- data/lib/insika/safety/corpus.rb +255 -0
- data/lib/insika/safety/detectors.rb +32 -113
- data/lib/insika/safety/factory.rb +15 -2
- data/lib/insika/safety/grounding_enforcer.rb +59 -0
- data/lib/insika/safety/grounding_validator.rb +49 -0
- data/lib/insika/safety/input_guardrail.rb +1 -1
- data/lib/insika/safety/moderator.rb +1 -1
- data/lib/insika/safety/output_filter.rb +9 -5
- data/lib/insika/safety/output_validator.rb +11 -5
- data/lib/insika/schedule.rb +177 -0
- data/lib/insika/schedule_engine.rb +314 -0
- data/lib/insika/schedule_store.rb +208 -0
- data/lib/insika/schema_guard.rb +35 -0
- data/lib/insika/server/app.rb +247 -17
- data/lib/insika/server/rack_app.rb +21 -1
- data/lib/insika/server/responses.rb +40 -1
- data/lib/insika/session_actor.rb +8 -4
- data/lib/insika/session_store.rb +65 -2
- data/lib/insika/settings_store.rb +10 -0
- data/lib/insika/shadow_pair_store.rb +258 -0
- data/lib/insika/skill_catalog.rb +16 -0
- data/lib/insika/soak/envelope.rb +140 -0
- data/lib/insika/soak/report.rb +392 -0
- data/lib/insika/soak/runner.rb +554 -0
- data/lib/insika/steer_injector.rb +21 -10
- data/lib/insika/store.rb +10 -1
- data/lib/insika/stores/memory.rb +6 -0
- data/lib/insika/stores/sqlite.rb +8 -0
- data/lib/insika/studio/app.rb +1437 -54
- data/lib/insika/studio/assets/dist/application.css +1 -1
- data/lib/insika/studio/assets/dist/application.js +23 -23
- data/lib/insika/studio/assets/dist/favicon.svg +6 -0
- data/lib/insika/studio/forms.rb +308 -10
- data/lib/insika/studio/nav_icons.rb +19 -1
- data/lib/insika/studio/views/_agent_tab_cache.erb +25 -0
- data/lib/insika/studio/views/_agent_tab_config.erb +514 -0
- data/lib/insika/studio/views/_agent_tab_history.erb +24 -0
- data/lib/insika/studio/views/_agent_tab_loops.erb +54 -0
- data/lib/insika/studio/views/_agent_tab_memory.erb +51 -0
- data/lib/insika/studio/views/_agent_tab_outcomes.erb +31 -0
- data/lib/insika/studio/views/_agent_tab_prompts.erb +108 -0
- data/lib/insika/studio/views/_agent_tab_skills.erb +38 -0
- data/lib/insika/studio/views/_agents_master.erb +44 -0
- data/lib/insika/studio/views/_message.erb +49 -32
- data/lib/insika/studio/views/agent_detail.erb +62 -278
- data/lib/insika/studio/views/agents.erb +71 -54
- data/lib/insika/studio/views/approvals.erb +4 -1
- data/lib/insika/studio/views/artifact.erb +23 -0
- data/lib/insika/studio/views/artifacts.erb +59 -0
- data/lib/insika/studio/views/chats.erb +4 -1
- data/lib/insika/studio/views/customer.erb +94 -0
- data/lib/insika/studio/views/customers.erb +32 -0
- data/lib/insika/studio/views/evals.erb +6 -3
- data/lib/insika/studio/views/facts.erb +133 -0
- data/lib/insika/studio/views/followups.erb +125 -0
- data/lib/insika/studio/views/funnel.erb +106 -0
- data/lib/insika/studio/views/harvest.erb +234 -0
- data/lib/insika/studio/views/home.erb +106 -66
- data/lib/insika/studio/views/knowledge.erb +123 -0
- data/lib/insika/studio/views/layout.erb +15 -11
- data/lib/insika/studio/views/mcp.erb +174 -80
- data/lib/insika/studio/views/parity.erb +147 -0
- data/lib/insika/studio/views/playground.erb +7 -1
- data/lib/insika/studio/views/session.erb +233 -124
- data/lib/insika/studio/views/settings.erb +41 -1
- data/lib/insika/studio/views/skills.erb +1 -2
- data/lib/insika/studio/views/system_files.erb +1 -1
- data/lib/insika/studio/views/task.erb +13 -0
- data/lib/insika/studio/views/tasks.erb +4 -1
- data/lib/insika/studio/views/tools.erb +24 -10
- data/lib/insika/task_store.rb +21 -1
- data/lib/insika/templates/browser-agent/README.md +36 -0
- data/lib/insika/templates/browser-agent/agent.rb +49 -0
- data/lib/insika/templates/daily-digest/README.md +38 -0
- data/lib/insika/templates/daily-digest/agent.rb +77 -0
- data/lib/insika/templates/repo-explorer/README.md +36 -0
- data/lib/insika/templates/repo-explorer/agent.rb +45 -0
- data/lib/insika/templates/research-analyst/README.md +26 -0
- data/lib/insika/templates/research-analyst/agent.rb +58 -0
- data/lib/insika/templates/review-panel/README.md +20 -0
- data/lib/insika/templates/review-panel/agent.rb +50 -0
- data/lib/insika/templates/travel-planner/README.md +35 -0
- data/lib/insika/templates/travel-planner/agent.rb +87 -0
- data/lib/insika/templates.rb +112 -0
- data/lib/insika/testing/store_contract.rb +27 -6
- data/lib/insika/tick.rb +47 -11
- data/lib/insika/timezone.rb +45 -0
- data/lib/insika/tool_definition.rb +17 -7
- data/lib/insika/tool_envelope.rb +69 -0
- data/lib/insika/tool_manifest.rb +5 -1
- data/lib/insika/tools/data_defined_tool.rb +10 -0
- data/lib/insika/tools/generate_image.rb +89 -0
- data/lib/insika/tools/load_knowledge.rb +74 -0
- data/lib/insika/tools/run_persona_eval.rb +328 -0
- data/lib/insika/tools/save_artifact.rb +95 -0
- data/lib/insika/tools/schedule_followup.rb +164 -0
- data/lib/insika/tools/tts.rb +47 -0
- data/lib/insika/tools/update_briefing.rb +126 -0
- data/lib/insika/turn_output.rb +1 -1
- data/lib/insika/turn_state.rb +49 -1
- data/lib/insika/turn_timing.rb +22 -2
- data/lib/insika/version.rb +1 -1
- data/lib/insika/vitals.rb +84 -0
- data/lib/insika/wiring/graph.rb +472 -13
- data/lib/insika/wiring/graph_chat.rb +102 -0
- data/lib/insika.rb +149 -3
- metadata +181 -5
- data/docs/build.md +0 -14
- data/docs/understand.md +0 -10
|
@@ -0,0 +1,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
676
|
+
.gsub('"', """).gsub("'", "'")
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
end
|