studio-engine 0.66.1 → 0.67.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50dd61991cf5d6c07313f0f8b3cff808d8207e866052d3d138bf804414e6157e
4
- data.tar.gz: f7e06ec09e8c833f4d0dc7334d5079ad82377763a81284c21c638f4cb8b02a9b
3
+ metadata.gz: 3ffd4e2260ecf5e4ed81cbe17b45001ac14b3bcbf576b4fd79b506bfa2fff6dc
4
+ data.tar.gz: d7f03282009eeb8f914637df864769fb5846808d4ccc270a1285c04564b18418
5
5
  SHA512:
6
- metadata.gz: d50292a80537c03f7aa76c1dbfece07b2f21b6abb5d497bf412767463862b1030137b0ad13f9f7ac1cac412401d246dbdcc7a73045bcafca767c1375622252fd
7
- data.tar.gz: 76983256a17c0cdcd0cb479a650a2f374a312b13b6d940117fd65783eb402e347eb8b6076daba41f535020f5e108e4b831d6743d1a0eb706da25d8f925e92209
6
+ metadata.gz: 1ab14b588d1d5382cc1738205472ba01b3f7aeb4442b026a506a824e777060eeae59ab7873d3176bfb6fd5f1ca95633a052e02b69ebe8f7715a6c9a8cdc1d821
7
+ data.tar.gz: 9256f176a552cea09a38ec5e56b32749ce4e419f5231b90971e6c14f2099983e6b4be646cdac8cc5efdef2e6d476714875c00ff1beae980f66c31752446c7123
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  ### Added
8
8
 
9
+ - **Knowledge layer primitive** — `Studio::KnowledgeDoc` + `/admin/knowledge`:
10
+ an S3-backed document store for an entity's business knowledge, built for the
11
+ McRitchie Industries acquisition agents (Samson/Dawn) but app-agnostic.
12
+ One row per document: entity, implicit folder `path` (the S3 key mirrors it,
13
+ so the bucket stays human-browsable), category, `document_date` (the as-of
14
+ date, distinct from upload time), `inbox → filed → superseded` lifecycle, and
15
+ a per-agent access map with three levels — `full` (reads the facts), `aware`
16
+ (knows the document exists and gets the safe `summary` + boundary line, not
17
+ the contents), `none` (does not see the row). "Aware" exists because an agent
18
+ with a hole in its context confabulates or stonewalls; one with an awareness
19
+ entry has something true to say and a boundary to hold.
20
+ The browser renders folder and flat ("show all") views with breadcrumbs,
21
+ entity/status filters, an inbox badge, per-agent access chips, and an intake
22
+ form whose uploads land as `inbox` for triage; downloads are 15-minute
23
+ presigned GETs. Storage goes through `Studio::S3` and **fails loudly** on an
24
+ unconfigured app (`NotConfigured` raised, plus a red banner on the browser) —
25
+ never a silent drop. Routes are opt-in (`Studio.draw_knowledge_routes`,
26
+ default off); `Studio.knowledge_agents` names the agent roster the intake UI
27
+ offers selects for. Ships the reference migration
28
+ `create_studio_knowledge_docs` — consumers run
29
+ `bin/rails studio_engine:install:migrations && bin/rails db:migrate`.
30
+ Deferred to a later cut, recorded on the task: coverage view against a
31
+ diligence tracker, recurring-series tracking, per-folder access defaults.
32
+
9
33
  - **A green comment-leak scan used to mean "not looked at".**
10
34
  `test/views/erb_comment_leak_test.rb` guards the ERB comment form in
11
35
  `app/views/**/*.erb`. It never looked inside `<script>`, and that is where this
data/Gemfile CHANGED
@@ -30,3 +30,28 @@ group :development, :test do
30
30
  # test fixture. Not a runtime dependency — consuming apps bring their own server.
31
31
  gem "puma", ">= 6.0"
32
32
  end
33
+
34
+ # solana-studio backs the style guide's THREE web3 specimens (Connect wallet and
35
+ # the two Sign Wallet cards). Those partials used to live here, in
36
+ # app/views/studio/modals; the two-template split moved them to the gem —
37
+ # BASE is studio-engine + mcritchie-studio, WEB3 ADD is solana-studio +
38
+ # turf-monster — because, as the call was made, "the real op sec vector is
39
+ # managing sessions safely. Anything wallet based should be in the app."
40
+ #
41
+ # DEVELOPMENT AND TEST ONLY, and deliberately NOT a gemspec runtime dependency.
42
+ # Declaring it there would push a Solana stack onto every BASE consumer
43
+ # (acquisition-studio, mcritchie-industries, moms-app mount this engine and
44
+ # bundle no solana-studio), which is precisely the coupling the split removed.
45
+ # It is here so the DUMMY app can resolve the gem's partials and the style guide
46
+ # renders the real shipped cards rather than a fork of them.
47
+ #
48
+ # There is no dependency cycle: solana-studio's GEMSPEC declares only ed25519.
49
+ # It lists studio-engine in its own Gemfile, dev-only, which Bundler never reads
50
+ # when solana-studio is consumed as a gem.
51
+ #
52
+ # The style guide self-gates on the partial RESOLVING (style/_modals.html.erb),
53
+ # so an app without this gem renders /admin/style with the web3 cards badged
54
+ # rather than raising a missing-template error.
55
+ group :development, :test do
56
+ gem "solana-studio", ">= 0.5.3"
57
+ end
data/README.md CHANGED
@@ -238,30 +238,28 @@ the block:
238
238
  <% end %>
239
239
  ```
240
240
 
241
- **Connect Wallet `studio/modals/wallet_connect`.** The reown-style wallet
242
- picker, engine-owned so the apps stop each keeping a copy. Register it like any
243
- other modal and configure it with locals:
244
-
245
- ```erb
246
- <template x-if="$store.modals.current().id === 'wallet-connect'">
247
- <%= render "studio/modals/wallet_connect" %>
248
- </template>
249
- ```
250
-
251
- Locals: `store` (default `"modals"`), `connect_fn` (the window function that
252
- connects AND verifies, default `"solanaConnectAndVerify"`), `title`, `slot` /
253
- `slot_locals` (a partial rendered before the wallet rows — an app's legal-age
254
- attestation goes here), and `extra_data` (a JS fragment merged into the
255
- component's `x-data`).
256
-
257
- App behaviour arrives as optional **hook methods** defined in `extra_data`, each
258
- called only when present: `onInit`, `canPick` (falsy aborts a pick or a deep
259
- link), `verifyArgs` (merged into the connect options), `onConnected(result)`,
260
- `onDeepLink`, `onBack`. An app that needs none passes no `extra_data`.
261
-
262
- The slot is a **named local, not a block** — `block_given?` is always true inside
263
- a compiled Rails partial, so a block-shaped slot yields the captured page body
264
- into the card whenever no block is passed.
241
+ **Wallet modals moved to `solana-studio`.** The Connect Wallet picker, the Web3
242
+ step-up card and the Phantom deep link used to ship here as
243
+ `studio/modals/wallet_connect`, `studio/modals/web3_step_up` and
244
+ `studio/solana/phantom_deeplink`. They now live in the **solana-studio** gem as
245
+ `solana_studio/modals/wallet_connect`, `solana_studio/modals/web3_step_up` and
246
+ `solana_studio/phantom_deeplink`; render them from those paths and read that
247
+ gem's README for their locals and hooks.
248
+
249
+ This is the two-template split: **BASE** is studio-engine + mcritchie-studio,
250
+ **WEB3 ADD** is solana-studio + turf-monster. The engine has no business
251
+ shipping wallet UI as the call was made, *"the real op sec vector is managing
252
+ sessions safely. Anything wallet based should be in the app."*
253
+
254
+ What the engine **keeps** is the SESSION half of Solana sign-in:
255
+ `SolanaSessionsController`, `Solana::SessionAuth`, the `/auth/solana/nonce` and
256
+ `/auth/solana/verify` routes, the `solana_sessions/phantom_callback` view, and
257
+ `Studio.wallet_sign_in_statement` the single source for the signed statement,
258
+ which the gem's deep link reads so the two cannot drift.
259
+
260
+ It also keeps two blocks the gem renders **by name** across the gem boundary:
261
+ `studio/modals/blocks/wallet_brand_sprite` and `studio/modals/blocks/card_header`.
262
+ Renaming either is a cross-repo change.
265
263
 
266
264
  **Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
267
265
  own modals (because not every consuming app renders a shared host, and the ones
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # /admin/knowledge — the knowledge layer's browser + intake surface.
5
+ #
6
+ # A plain host-inherited controller whose views are bare content wrappers,
7
+ # like /admin/geo, so pages render inside each host's application layout and
8
+ # pick up that app's navbar and theme. Routes are opt-in
9
+ # (Studio.draw_knowledge_routes) — see Studio.routes.
10
+ #
11
+ # Uploads land as status "inbox"; an agent running the knowledge-intake SOP
12
+ # (or the operator, on the show page) files them — classification is a
13
+ # deliberate second step, not a side effect of upload.
14
+ class KnowledgeDocsController < ApplicationController
15
+ before_action :require_admin
16
+ before_action :set_doc, only: [:show, :update, :download]
17
+
18
+ VIEWS = %w[folders flat].freeze
19
+
20
+ def index
21
+ @view = VIEWS.include?(params[:view]) ? params[:view] : "folders"
22
+ @folder = Studio::KnowledgeDoc.normalize_path(params[:folder])
23
+ @entity = params[:entity].presence
24
+ @status = Studio::KnowledgeDoc::STATUSES.include?(params[:status]) ? params[:status] : nil
25
+
26
+ scope = Studio::KnowledgeDoc.order(created_at: :desc)
27
+ scope = scope.for_entity(@entity) if @entity
28
+ scope = scope.where(status: @status) if @status
29
+
30
+ @entities = Studio::KnowledgeDoc.distinct.pluck(:entity).sort
31
+ @inbox_size = scope.inbox.count
32
+
33
+ if @view == "folders"
34
+ @folders = scope.folders_under(@folder)
35
+ @docs = scope.in_folder(@folder)
36
+ else
37
+ @folders = []
38
+ @docs = scope
39
+ end
40
+ end
41
+
42
+ def show
43
+ end
44
+
45
+ def create
46
+ file = params.dig(:knowledge_doc, :file)
47
+ doc = Studio::KnowledgeDoc.intake!(
48
+ doc_params.merge(uploaded_by: current_user&.email, status: "inbox"),
49
+ file: file
50
+ )
51
+ redirect_to admin_knowledge_doc_path(doc), notice: "#{doc.title} landed in the inbox."
52
+ rescue Studio::S3::NotConfigured, Studio::KnowledgeDoc::MissingTable => e
53
+ redirect_to admin_knowledge_path, alert: e.message
54
+ rescue ActiveRecord::RecordInvalid => e
55
+ redirect_to admin_knowledge_path, alert: e.message
56
+ end
57
+
58
+ def update
59
+ @doc.update!(doc_params)
60
+ redirect_to admin_knowledge_doc_path(@doc), notice: "#{@doc.title} updated."
61
+ rescue ActiveRecord::RecordInvalid => e
62
+ redirect_to admin_knowledge_doc_path(@doc), alert: e.message
63
+ end
64
+
65
+ def download
66
+ return redirect_to admin_knowledge_doc_path(@doc), alert: "No file attached." unless @doc.file?
67
+
68
+ redirect_to @doc.signed_url, allow_other_host: true
69
+ end
70
+
71
+ private
72
+
73
+ def set_doc
74
+ @doc = Studio::KnowledgeDoc.find(params[:id])
75
+ end
76
+
77
+ def doc_params
78
+ permitted = params.require(:knowledge_doc)
79
+ .permit(:title, :entity, :path, :category, :summary,
80
+ :document_date, :source_note, :status, access: {})
81
+ # The access map arrives as {"samson" => "full", ...}; drop blanks so an
82
+ # untouched select doesn't write a "none" the map means by absence anyway.
83
+ if permitted[:access]
84
+ permitted[:access] = permitted[:access].to_h.reject { |_agent, level| level.blank? }
85
+ end
86
+ permitted
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,207 @@
1
+ module Studio
2
+ # One row per document in an entity's knowledge layer: the S3 object pointer,
3
+ # the filing metadata (entity, folder path, category, as-of date), and the
4
+ # per-agent access map that says who may read it and at what depth.
5
+ #
6
+ # The access model has three levels, because "not allowed to read it" and
7
+ # "does not know it exists" are different states:
8
+ #
9
+ # full — the agent reads the document and its facts.
10
+ # aware — the agent knows the document exists and gets `summary` (the safe
11
+ # summary + boundary line), not the contents. An agent with a HOLE
12
+ # in its context confabulates or stonewalls; one with an awareness
13
+ # entry has something true to say and a boundary to hold.
14
+ # none — the agent does not see the row at all (the default).
15
+ #
16
+ # Folders are implicit: a document claims a `path` and the folder exists.
17
+ # The S3 key mirrors entity + path, so the bucket stays human-browsable.
18
+ # Uploads land as status "inbox" for agent triage (the knowledge-intake SOP);
19
+ # filing flips them "filed"; a replacement marks the old row "superseded".
20
+ #
21
+ # Storage goes through Studio::S3 and FAILS LOUDLY on an unconfigured app
22
+ # (Studio::S3::NotConfigured) — never wrap intake in a rescue that returns
23
+ # success; a QA lane once lost weeks of writes to exactly that.
24
+ #
25
+ # Like Studio::Link, the table is installed per consumer app by
26
+ # `bin/rails studio_engine:install:migrations && bin/rails db:migrate`.
27
+ class KnowledgeDoc < ApplicationRecord
28
+ self.table_name = "studio_knowledge_docs"
29
+
30
+ # The app drew knowledge routes but never installed the table. Raised in
31
+ # place of a bare PG::UndefinedTable so the first person to hit it reads
32
+ # the fix instead of an adapter error — see .intake!.
33
+ class MissingTable < StandardError; end
34
+
35
+ ACCESS_LEVELS = %w[full aware none].freeze
36
+ STATUSES = %w[inbox filed superseded].freeze
37
+
38
+ validates :title, presence: true
39
+ validates :entity, presence: true
40
+ validates :status, inclusion: { in: STATUSES }
41
+ validate :access_levels_are_known
42
+
43
+ before_validation :normalize_fields
44
+
45
+ scope :for_entity, ->(entity) { where(entity: entity) }
46
+ scope :inbox, -> { where(status: "inbox") }
47
+ scope :filed, -> { where(status: "filed") }
48
+ scope :active, -> { where.not(status: "superseded") }
49
+ scope :in_folder, ->(path) { where(path: normalize_path(path)) }
50
+ scope :under, lambda { |base|
51
+ base = normalize_path(base)
52
+ base.empty? ? all : where("path = ? OR path LIKE ?", base, "#{base}/%")
53
+ }
54
+
55
+ class << self
56
+ # Create + upload in one call — the write path the intake UI and the
57
+ # knowledge-intake SOP use. `file` responds to #read (an uploaded file);
58
+ # metadata-only records (file: nil) are legal.
59
+ #
60
+ # Table-missing is checked up front so the failure names its fix before
61
+ # any bytes reach S3.
62
+ def intake!(attrs, file: nil)
63
+ unless table_exists?
64
+ raise MissingTable,
65
+ "The knowledge layer needs the studio_knowledge_docs table, and #{Studio.app_name} has " \
66
+ "no such table. Run `bin/rails studio_engine:install:migrations && bin/rails db:migrate` " \
67
+ "(install ALL of them). Do not hand-copy the migration — it collides with the installed " \
68
+ "copy on `class CreateStudioKnowledgeDocs`."
69
+ end
70
+
71
+ doc = new(attrs)
72
+ doc.title = default_title(file) if doc.title.blank? && file
73
+ doc.validate!
74
+ doc.attach!(file) if file
75
+ doc.save!
76
+ doc
77
+ end
78
+
79
+ # Immediate child folder names under `base` for this scope, derived from
80
+ # the paths documents actually claim — there is no folder table.
81
+ #
82
+ # unscope(:order) is load-bearing: callers hand in display-ordered scopes,
83
+ # and Postgres refuses SELECT DISTINCT with an ORDER BY column outside the
84
+ # select list (SQLite tolerates it, so only a real consumer sees the 500).
85
+ def folders_under(base = "")
86
+ base = normalize_path(base)
87
+ prefix = base.empty? ? "" : "#{base}/"
88
+ unscope(:order).distinct.pluck(:path).filter_map { |path|
89
+ next if path == base || !path.start_with?(prefix)
90
+
91
+ path.delete_prefix(prefix).split("/").first
92
+ }.uniq.sort
93
+ end
94
+
95
+ # "/a//b/" -> "a/b". Nil-safe; the empty string is the root folder.
96
+ def normalize_path(value)
97
+ value.to_s.strip.squeeze("/").delete_prefix("/").delete_suffix("/")
98
+ end
99
+
100
+ def default_title(file)
101
+ name = file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.to_s)
102
+ File.basename(name.to_s, ".*").tr("_-", " ").squeeze(" ").strip.presence
103
+ end
104
+ end
105
+
106
+ # --- access ---------------------------------------------------------------
107
+
108
+ def access_for(agent)
109
+ level = access.is_a?(Hash) ? access[agent.to_s] : nil
110
+ ACCESS_LEVELS.include?(level) ? level : "none"
111
+ end
112
+
113
+ # full or aware — the agent may know this document exists.
114
+ def visible_to?(agent)
115
+ access_for(agent) != "none"
116
+ end
117
+
118
+ def full_for?(agent)
119
+ access_for(agent) == "full"
120
+ end
121
+
122
+ # --- storage --------------------------------------------------------------
123
+
124
+ def file?
125
+ s3_key.present?
126
+ end
127
+
128
+ # Upload the file's bytes and point this row at them. The key mirrors
129
+ # entity + path so the bucket reads like the folder tree. Raises
130
+ # Studio::S3::NotConfigured on an app with no bucket — deliberately.
131
+ def attach!(file)
132
+ filename = file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.to_s)
133
+ content_type = file.respond_to?(:content_type) ? file.content_type : nil
134
+ body = file.respond_to?(:read) ? file.read : file.to_s
135
+
136
+ key = [
137
+ "knowledge", entity, path.presence,
138
+ "#{Time.current.strftime('%Y%m%d%H%M%S')}-#{self.class.sanitize_filename(filename)}"
139
+ ].compact.join("/")
140
+
141
+ Studio::S3.upload(key: key, body: body, content_type: content_type)
142
+ self.s3_key = key
143
+ self.mime_type = content_type if content_type
144
+ self.byte_size = body.bytesize if body.respond_to?(:bytesize)
145
+ self
146
+ end
147
+
148
+ # 15-minute presigned GET — the only way a private object leaves the bucket.
149
+ def signed_url(expires_in: 900)
150
+ raise Studio::S3::Error, "no file attached to #{title.inspect}" unless file?
151
+
152
+ Studio::S3.signed_url(key: s3_key, expires_in: expires_in)
153
+ end
154
+
155
+ # --- lifecycle ------------------------------------------------------------
156
+
157
+ def supersede_with!(replacement)
158
+ update!(status: "superseded", superseded_by_id: replacement.id)
159
+ end
160
+
161
+ def superseded?
162
+ status == "superseded"
163
+ end
164
+
165
+ def folder_segments
166
+ path.blank? ? [] : path.split("/")
167
+ end
168
+
169
+ # The date the row sorts and displays by: the document's own as-of date,
170
+ # falling back to upload time for undated material.
171
+ def display_date
172
+ document_date || created_at&.to_date
173
+ end
174
+
175
+ def self.sanitize_filename(name)
176
+ base = name.to_s.strip
177
+ return "document" if base.empty?
178
+
179
+ base.gsub(/[^A-Za-z0-9._-]+/, "-").squeeze("-")
180
+ .gsub(/-(?=\.)|\A-|-\z/, "").downcase
181
+ .presence || "document"
182
+ end
183
+
184
+ private
185
+
186
+ def normalize_fields
187
+ self.path = self.class.normalize_path(path)
188
+ self.entity = entity.to_s.strip.downcase if entity
189
+ if access.is_a?(Hash)
190
+ self.access = access.each_with_object({}) do |(agent, level), map|
191
+ map[agent.to_s.strip.downcase] = level.to_s.strip.downcase if agent.present?
192
+ end
193
+ end
194
+ end
195
+
196
+ def access_levels_are_known
197
+ return if access.blank?
198
+ return errors.add(:access, "must be a map of agent => level") unless access.is_a?(Hash)
199
+
200
+ access.each do |agent, level|
201
+ unless ACCESS_LEVELS.include?(level)
202
+ errors.add(:access, "level for #{agent.inspect} must be one of #{ACCESS_LEVELS.join(', ')}")
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -313,11 +313,12 @@
313
313
  dbg('Verify result', JSON.stringify(result));
314
314
  if (result.success) {
315
315
  cleanup();
316
- // Guarded, matching studio/modals/_wallet_connect two files over. An app
317
- // that does not define this hook would otherwise throw HERE after a
318
- // successful verify, with the session already set so the user is
319
- // signed in and the page dies before redirecting. The engine cannot
320
- // require a hook it does not ship.
316
+ // Guarded, matching solana-studio's wallet_connect picker (the partial
317
+ // that used to sit two files over, before the two-template split moved
318
+ // the wallet UI to the gem). An app that does not define this hook would
319
+ // otherwise throw HERE after a successful verify, with the session
320
+ // already set so the user is signed in and the page dies before
321
+ // redirecting. The engine cannot require a hook it does not ship.
321
322
  if (typeof window.handleSolanaVerifySuccess === 'function') window.handleSolanaVerifySuccess(result);
322
323
  dbg('OK', 'Login success! Redirecting...');
323
324
  window.location.href = result.redirect || '/';
@@ -0,0 +1,10 @@
1
+ <%# Per-agent access chips — who may know this document, at which depth.
2
+ Renders the map the document actually carries (any agent slug), so a chip
3
+ never lies about roster membership. Locals: doc:. %>
4
+ <% if doc.access.blank? %>
5
+ <span class="knowledge-chip knowledge-chip-none">no agents</span>
6
+ <% else %>
7
+ <% doc.access.sort.each do |agent, level| %>
8
+ <span class="knowledge-chip knowledge-chip-<%= level %>"><%= agent %> · <%= level %></span>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,115 @@
1
+ <%# The knowledge-layer browser. Locals:
2
+ view: "folders" | "flat"
3
+ folder: current folder path ("" = root; folder view only)
4
+ entity: entity filter or nil
5
+ status: status filter or nil
6
+ entities: all entity slugs (for the filter row)
7
+ folders: immediate child folder names (folder view)
8
+ docs: the documents to list
9
+ inbox_size: count of inbox docs in the current scope
10
+
11
+ Styling note: gem-specific looks live in the scoped <style> below because a
12
+ host's Tailwind build may not scan this gem's views (same reasoning as the
13
+ geo grid). Layout leans on utilities every host already ships. %>
14
+ <style>
15
+ .knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
16
+ font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
17
+ .knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
18
+ .knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
19
+ .knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
20
+ .knowledge-status { font-size: .7rem; padding: 0 .45rem; border-radius: .25rem; border: 1px solid rgb(148 163 184 / .5); }
21
+ .knowledge-status-inbox { border-color: rgb(245 158 11 / .6); color: rgb(180 83 9); }
22
+ .knowledge-status-superseded { text-decoration: line-through; opacity: .6; }
23
+ .knowledge-folder { display: inline-flex; align-items: center; gap: .4rem; padding: .35rem .75rem;
24
+ border: 1px solid rgb(148 163 184 / .4); border-radius: .5rem; }
25
+ </style>
26
+
27
+ <% unless Studio::S3.configured? %>
28
+ <div id="knowledge-storage-warning" class="mb-4 p-3 border rounded" style="border-color: rgb(239 68 68 / .5); color: rgb(220 38 38);">
29
+ Object storage is not configured — set <code>Studio.s3_bucket_prefix</code> in
30
+ <code>config/initializers/studio.rb</code>. Uploads will refuse rather than
31
+ silently drop files.
32
+ </div>
33
+ <% end %>
34
+
35
+ <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
36
+ <h1 class="text-xl font-semibold">
37
+ Knowledge
38
+ <% if inbox_size.to_i.positive? %>
39
+ <%= link_to "#{inbox_size} in inbox",
40
+ admin_knowledge_path(view: "flat", entity: entity, status: "inbox"),
41
+ id: "knowledge-inbox-badge", class: "knowledge-status knowledge-status-inbox ml-2 align-middle" %>
42
+ <% end %>
43
+ </h1>
44
+ <div id="knowledge-view-toggle" class="flex items-center gap-2 text-sm">
45
+ <%= link_to "Folders", admin_knowledge_path(view: "folders", entity: entity, status: status),
46
+ class: view == "folders" ? "font-semibold underline" : "" %>
47
+ <span aria-hidden="true">·</span>
48
+ <%= link_to "Show all", admin_knowledge_path(view: "flat", entity: entity, status: status),
49
+ class: view == "flat" ? "font-semibold underline" : "" %>
50
+ </div>
51
+ </div>
52
+
53
+ <% if entities.length > 1 || entity %>
54
+ <div id="knowledge-entity-filter" class="flex flex-wrap items-center gap-2 mb-3 text-sm">
55
+ <%= link_to "All entities", admin_knowledge_path(view: view),
56
+ class: entity ? "" : "font-semibold underline" %>
57
+ <% entities.each do |candidate| %>
58
+ <%= link_to candidate, admin_knowledge_path(view: view, entity: candidate),
59
+ class: candidate == entity ? "font-semibold underline" : "" %>
60
+ <% end %>
61
+ </div>
62
+ <% end %>
63
+
64
+ <% if view == "folders" %>
65
+ <nav id="knowledge-breadcrumbs" class="text-sm mb-3">
66
+ <%= link_to "root", admin_knowledge_path(view: "folders", entity: entity, status: status) %>
67
+ <% segments = folder.blank? ? [] : folder.split("/") %>
68
+ <% segments.each_with_index do |segment, index| %>
69
+ <span aria-hidden="true">/</span>
70
+ <%= link_to segment,
71
+ admin_knowledge_path(view: "folders", entity: entity, status: status,
72
+ folder: segments[0..index].join("/")) %>
73
+ <% end %>
74
+ </nav>
75
+
76
+ <% if folders.any? %>
77
+ <div class="flex flex-wrap gap-2 mb-4">
78
+ <% folders.each do |name| %>
79
+ <%= link_to admin_knowledge_path(view: "folders", entity: entity, status: status,
80
+ folder: [folder.presence, name].compact.join("/")),
81
+ id: "knowledge-folder-#{name.parameterize}", class: "knowledge-folder" do %>
82
+ <span aria-hidden="true">📁</span> <%= name %>
83
+ <% end %>
84
+ <% end %>
85
+ </div>
86
+ <% end %>
87
+ <% end %>
88
+
89
+ <% if docs.any? %>
90
+ <div class="overflow-x-auto">
91
+ <table id="knowledge-docs" class="w-full text-sm">
92
+ <thead>
93
+ <tr class="text-left border-b">
94
+ <th class="py-2 pr-3">Document</th>
95
+ <% if view == "flat" %><th class="py-2 pr-3">Folder</th><% end %>
96
+ <th class="py-2 pr-3">Category</th>
97
+ <th class="py-2 pr-3">As of</th>
98
+ <th class="py-2 pr-3">Status</th>
99
+ <th class="py-2 pr-3">Access</th>
100
+ </tr>
101
+ </thead>
102
+ <tbody>
103
+ <% docs.each do |doc| %>
104
+ <%= render "studio/knowledge_docs/doc_row", doc: doc, view: view %>
105
+ <% end %>
106
+ </tbody>
107
+ </table>
108
+ </div>
109
+ <% else %>
110
+ <p id="knowledge-empty" class="text-sm opacity-70 mb-4">
111
+ <%= view == "folders" && folder.present? ? "This folder holds no documents." : "No documents yet — upload the first one below." %>
112
+ </p>
113
+ <% end %>
114
+
115
+ <%= render "studio/knowledge_docs/upload_form", entity: entity, folder: folder %>
@@ -0,0 +1,18 @@
1
+ <%# One document row. Locals: doc:, view: ("folders" | "flat"). %>
2
+ <tr id="knowledge-doc-<%= doc.id %>" class="border-b align-top <%= "opacity-60" if doc.superseded? %>">
3
+ <td class="py-2 pr-3">
4
+ <%= link_to doc.title, admin_knowledge_doc_path(doc), class: "font-medium" %>
5
+ <% if doc.summary.present? %>
6
+ <div class="text-xs opacity-70 max-w-md truncate"><%= doc.summary %></div>
7
+ <% end %>
8
+ </td>
9
+ <% if view == "flat" %>
10
+ <td class="py-2 pr-3 text-xs opacity-80"><%= doc.path.presence || "root" %></td>
11
+ <% end %>
12
+ <td class="py-2 pr-3"><%= doc.category %></td>
13
+ <td class="py-2 pr-3 whitespace-nowrap"><%= doc.display_date&.iso8601 %></td>
14
+ <td class="py-2 pr-3">
15
+ <span class="knowledge-status knowledge-status-<%= doc.status %>"><%= doc.status %></span>
16
+ </td>
17
+ <td class="py-2 pr-3"><%= render "studio/knowledge_docs/access_chips", doc: doc %></td>
18
+ </tr>
@@ -0,0 +1,48 @@
1
+ <%# Intake form — uploads land as status "inbox" for triage; classification is
2
+ the second step (the show page / the knowledge-intake SOP), so this form
3
+ asks only for what the uploader knows at drop time. Locals: entity:, folder:
4
+ (both may be blank; they pre-fill from where the browser stands). %>
5
+ <div id="knowledge-upload" class="mt-6 p-4 border rounded">
6
+ <h2 class="font-semibold mb-3">Add a document</h2>
7
+ <%= form_with url: admin_knowledge_path, method: :post, multipart: true, local: true do |form| %>
8
+ <div class="grid gap-3 md:grid-cols-2">
9
+ <label class="block text-sm">
10
+ <span class="block mb-1">File</span>
11
+ <%= form.file_field "knowledge_doc[file]", required: true, class: "block w-full text-sm" %>
12
+ </label>
13
+ <label class="block text-sm">
14
+ <span class="block mb-1">Title <span class="opacity-60">(blank = from filename)</span></span>
15
+ <%= form.text_field "knowledge_doc[title]", class: "block w-full border rounded px-2 py-1" %>
16
+ </label>
17
+ <label class="block text-sm">
18
+ <span class="block mb-1">Entity</span>
19
+ <%= form.text_field "knowledge_doc[entity]", value: entity, required: true,
20
+ placeholder: "commercial-welding-llc", class: "block w-full border rounded px-2 py-1" %>
21
+ </label>
22
+ <label class="block text-sm">
23
+ <span class="block mb-1">Folder</span>
24
+ <%= form.text_field "knowledge_doc[path]", value: folder,
25
+ placeholder: "financials/aging-inventory/2026-08", class: "block w-full border rounded px-2 py-1" %>
26
+ </label>
27
+ <label class="block text-sm">
28
+ <span class="block mb-1">Document date <span class="opacity-60">(the as-of date, not today)</span></span>
29
+ <%= form.date_field "knowledge_doc[document_date]", class: "block w-full border rounded px-2 py-1" %>
30
+ </label>
31
+ <label class="block text-sm">
32
+ <span class="block mb-1">Source <span class="opacity-60">(who/where it came from)</span></span>
33
+ <%= form.text_field "knowledge_doc[source_note]", class: "block w-full border rounded px-2 py-1" %>
34
+ </label>
35
+ <% Array(Studio.knowledge_agents).each do |agent| %>
36
+ <label class="block text-sm" id="knowledge-access-<%= agent %>">
37
+ <span class="block mb-1">Access — <%= agent %></span>
38
+ <%= form.select "knowledge_doc[access][#{agent}]",
39
+ [["none (default)", ""], ["aware — knows it exists", "aware"], ["full — reads the facts", "full"]],
40
+ {}, class: "block w-full border rounded px-2 py-1" %>
41
+ </label>
42
+ <% end %>
43
+ </div>
44
+ <div class="mt-3">
45
+ <%= form.submit "Upload to inbox", class: "px-3 py-1.5 border rounded font-medium" %>
46
+ </div>
47
+ <% end %>
48
+ </div>
@@ -0,0 +1,6 @@
1
+ <%# /admin/knowledge — bare content wrapper; the browser partial is the
2
+ primitive (renderable + testable on its own, like studio/board/_board). %>
3
+ <%= render "studio/knowledge_docs/browser",
4
+ view: @view, folder: @folder, entity: @entity, status: @status,
5
+ entities: @entities, folders: @folders, docs: @docs,
6
+ inbox_size: @inbox_size %>