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 +4 -4
- data/CHANGELOG.md +24 -0
- data/Gemfile +25 -0
- data/README.md +22 -24
- data/app/controllers/studio/knowledge_docs_controller.rb +89 -0
- data/app/models/studio/knowledge_doc.rb +207 -0
- data/app/views/solana_sessions/phantom_callback.html.erb +6 -5
- data/app/views/studio/knowledge_docs/_access_chips.html.erb +10 -0
- data/app/views/studio/knowledge_docs/_browser.html.erb +115 -0
- data/app/views/studio/knowledge_docs/_doc_row.html.erb +18 -0
- data/app/views/studio/knowledge_docs/_upload_form.html.erb +48 -0
- data/app/views/studio/knowledge_docs/index.html.erb +6 -0
- data/app/views/studio/knowledge_docs/show.html.erb +87 -0
- data/app/views/style/_modals.html.erb +73 -21
- data/app/views/style/modals/_wallet_connect.html.erb +11 -5
- data/db/migrate/20260901000001_create_studio_knowledge_docs.rb +49 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +21 -0
- metadata +10 -5
- data/app/views/studio/modals/_wallet_connect.html.erb +0 -330
- data/app/views/studio/modals/_web3_step_up.html.erb +0 -292
- data/app/views/studio/solana/_deeplink_assets.html.erb +0 -37
- data/app/views/studio/solana/_phantom_deeplink.html.erb +0 -167
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ffd4e2260ecf5e4ed81cbe17b45001ac14b3bcbf576b4fd79b506bfa2fff6dc
|
|
4
|
+
data.tar.gz: d7f03282009eeb8f914637df864769fb5846808d4ccc270a1285c04564b18418
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
317
|
-
// that
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
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 %>
|