studio-engine 0.66.2 → 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/app/controllers/studio/knowledge_docs_controller.rb +89 -0
- data/app/models/studio/knowledge_doc.rb +207 -0
- 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 +33 -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 -1
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
|
|
@@ -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
|
|
@@ -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 %>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<%# /admin/knowledge/:id — one document: metadata, access, download, and the
|
|
2
|
+
triage form that files it out of the inbox. %>
|
|
3
|
+
<style>
|
|
4
|
+
.knowledge-chip { display: inline-block; padding: 0 .5rem; border-radius: 9999px;
|
|
5
|
+
font-size: .7rem; line-height: 1.4rem; border: 1px solid transparent; white-space: nowrap; }
|
|
6
|
+
.knowledge-chip-full { background: rgb(16 185 129 / .12); border-color: rgb(16 185 129 / .4); color: rgb(5 150 105); }
|
|
7
|
+
.knowledge-chip-aware { background: rgb(245 158 11 / .12); border-color: rgb(245 158 11 / .4); color: rgb(180 83 9); }
|
|
8
|
+
.knowledge-chip-none { background: rgb(148 163 184 / .12); border-color: rgb(148 163 184 / .4); color: rgb(100 116 139); }
|
|
9
|
+
</style>
|
|
10
|
+
|
|
11
|
+
<div class="mb-4 text-sm">
|
|
12
|
+
<%= link_to "← Knowledge", admin_knowledge_path(folder: @doc.path.presence) %>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div class="flex flex-wrap items-start justify-between gap-3 mb-4">
|
|
16
|
+
<div>
|
|
17
|
+
<h1 class="text-xl font-semibold"><%= @doc.title %></h1>
|
|
18
|
+
<div class="text-sm opacity-70"><%= @doc.entity %> · <%= @doc.path.presence || "root" %></div>
|
|
19
|
+
</div>
|
|
20
|
+
<% if @doc.file? %>
|
|
21
|
+
<%= link_to "Download (15-min link)", admin_knowledge_doc_download_path(@doc),
|
|
22
|
+
id: "knowledge-download", class: "px-3 py-1.5 border rounded font-medium" %>
|
|
23
|
+
<% end %>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<dl id="knowledge-doc-meta" class="grid gap-x-8 gap-y-2 md:grid-cols-2 text-sm mb-6">
|
|
27
|
+
<div><dt class="opacity-60">Status</dt><dd><%= @doc.status %></dd></div>
|
|
28
|
+
<div><dt class="opacity-60">Category</dt><dd><%= @doc.category.presence || "—" %></dd></div>
|
|
29
|
+
<div><dt class="opacity-60">Document date</dt><dd><%= @doc.document_date&.iso8601 || "—" %></dd></div>
|
|
30
|
+
<div><dt class="opacity-60">Uploaded</dt><dd><%= @doc.created_at&.to_date&.iso8601 %> by <%= @doc.uploaded_by.presence || "—" %></dd></div>
|
|
31
|
+
<div><dt class="opacity-60">Source</dt><dd><%= @doc.source_note.presence || "—" %></dd></div>
|
|
32
|
+
<div><dt class="opacity-60">File</dt><dd><%= @doc.file? ? "#{@doc.mime_type.presence || 'file'} · #{number_to_human_size(@doc.byte_size) if @doc.byte_size}" : "metadata only" %></dd></div>
|
|
33
|
+
<div class="md:col-span-2"><dt class="opacity-60">Access</dt><dd><%= render "studio/knowledge_docs/access_chips", doc: @doc %></dd></div>
|
|
34
|
+
</dl>
|
|
35
|
+
|
|
36
|
+
<% if @doc.summary.present? %>
|
|
37
|
+
<div class="mb-6">
|
|
38
|
+
<h2 class="font-semibold mb-1">Summary <span class="text-xs opacity-60">(what an "aware" agent may read)</span></h2>
|
|
39
|
+
<p class="text-sm whitespace-pre-line"><%= @doc.summary %></p>
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
42
|
+
|
|
43
|
+
<div id="knowledge-triage" class="p-4 border rounded">
|
|
44
|
+
<h2 class="font-semibold mb-3">File it</h2>
|
|
45
|
+
<%= form_with url: admin_knowledge_doc_path(@doc), method: :patch, local: true do |form| %>
|
|
46
|
+
<div class="grid gap-3 md:grid-cols-2">
|
|
47
|
+
<label class="block text-sm">
|
|
48
|
+
<span class="block mb-1">Title</span>
|
|
49
|
+
<%= form.text_field "knowledge_doc[title]", value: @doc.title, class: "block w-full border rounded px-2 py-1" %>
|
|
50
|
+
</label>
|
|
51
|
+
<label class="block text-sm">
|
|
52
|
+
<span class="block mb-1">Folder</span>
|
|
53
|
+
<%= form.text_field "knowledge_doc[path]", value: @doc.path, class: "block w-full border rounded px-2 py-1" %>
|
|
54
|
+
</label>
|
|
55
|
+
<label class="block text-sm">
|
|
56
|
+
<span class="block mb-1">Category</span>
|
|
57
|
+
<%= form.text_field "knowledge_doc[category]", value: @doc.category, class: "block w-full border rounded px-2 py-1" %>
|
|
58
|
+
</label>
|
|
59
|
+
<label class="block text-sm">
|
|
60
|
+
<span class="block mb-1">Document date</span>
|
|
61
|
+
<%= form.date_field "knowledge_doc[document_date]", value: @doc.document_date, class: "block w-full border rounded px-2 py-1" %>
|
|
62
|
+
</label>
|
|
63
|
+
<label class="block text-sm">
|
|
64
|
+
<span class="block mb-1">Status</span>
|
|
65
|
+
<%= form.select "knowledge_doc[status]",
|
|
66
|
+
Studio::KnowledgeDoc::STATUSES.map { |s| [s, s] },
|
|
67
|
+
{ selected: @doc.status }, class: "block w-full border rounded px-2 py-1" %>
|
|
68
|
+
</label>
|
|
69
|
+
<% (Array(Studio.knowledge_agents) | @doc.access.keys).each do |agent| %>
|
|
70
|
+
<label class="block text-sm">
|
|
71
|
+
<span class="block mb-1">Access — <%= agent %></span>
|
|
72
|
+
<%= form.select "knowledge_doc[access][#{agent}]",
|
|
73
|
+
[["none", ""], ["aware", "aware"], ["full", "full"]],
|
|
74
|
+
{ selected: @doc.access_for(agent) == "none" ? "" : @doc.access_for(agent) },
|
|
75
|
+
class: "block w-full border rounded px-2 py-1" %>
|
|
76
|
+
</label>
|
|
77
|
+
<% end %>
|
|
78
|
+
<label class="block text-sm md:col-span-2">
|
|
79
|
+
<span class="block mb-1">Summary (safe for "aware" agents)</span>
|
|
80
|
+
<%= form.text_area "knowledge_doc[summary]", value: @doc.summary, rows: 3, class: "block w-full border rounded px-2 py-1" %>
|
|
81
|
+
</label>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="mt-3">
|
|
84
|
+
<%= form.submit "Save", class: "px-3 py-1.5 border rounded font-medium" %>
|
|
85
|
+
</div>
|
|
86
|
+
<% end %>
|
|
87
|
+
</div>
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
through studio/cropper_assets), Web3 (wallet-connect and web3-step-up now
|
|
22
22
|
rendered from the solana-studio gem / on-chain-tx / deposit / the generic
|
|
23
23
|
entry-confirmed celebration, gated by Studio.feature?(:web3) —
|
|
24
|
-
disabled-but-present-yet-openable when off
|
|
24
|
+
disabled-but-present-yet-openable when off, except the two gem-backed ids,
|
|
25
|
+
which an app without solana-studio lists but cannot open),
|
|
25
26
|
System and status (the reusable card blocks), Templates (the copy-from
|
|
26
27
|
archetypes), and Rewards (level-up + the entry-confirmed seeds bar + the
|
|
27
28
|
Free Entry Earned reward, gated by :leveling).
|
|
@@ -53,6 +54,25 @@
|
|
|
53
54
|
# (studio/modals/_host.html.erb): name, prefixes, partial.
|
|
54
55
|
web3_gem = lookup_context.exists?("wallet_connect", ["solana_studio/modals"], true)
|
|
55
56
|
|
|
57
|
+
# web3_gem gates the REGISTRATION below. It must also gate the TRIGGER on the
|
|
58
|
+
# three gem-backed cards (Connect wallet + the two Sign Wallet cards), or they
|
|
59
|
+
# stay role=button over a modal id nothing registered: a card that opens an
|
|
60
|
+
# EMPTY panel. The registration gate cannot prevent that on its own — it is
|
|
61
|
+
# what stops the missing-template 500, one layer down.
|
|
62
|
+
#
|
|
63
|
+
# Both terms are load-bearing, because the two gates answer different
|
|
64
|
+
# questions and a BASE app can fail either:
|
|
65
|
+
# openable: web3_gem -> with the capability OFF, the section's
|
|
66
|
+
# disabled-but-openable preview contract would otherwise keep the card
|
|
67
|
+
# clickable (clickable is !(disabled AND NOT openable)).
|
|
68
|
+
# disabled: web3_card_off -> with the capability ON but no gem, `disabled`
|
|
69
|
+
# alone is false, so the card renders as a NORMAL live card: no badge, no
|
|
70
|
+
# aria-disabled, and clickable whatever `openable` says. That is the worse
|
|
71
|
+
# of the two, and it is the exact scenario the BASE-app test runs in.
|
|
72
|
+
# The other six web3 specimens are engine-owned and registered
|
|
73
|
+
# unconditionally, so they keep the plain capability gate.
|
|
74
|
+
web3_card_off = !web3_on || !web3_gem
|
|
75
|
+
|
|
56
76
|
# Auth method-toggle defaults — mirror the app's Studio.auth_method? config;
|
|
57
77
|
# Solana Wallet also needs the web3 capability, so it defaults OFF on an app
|
|
58
78
|
# (like McRitchie Studio) that ships web3 off.
|
|
@@ -1077,7 +1097,9 @@
|
|
|
1077
1097
|
|
|
1078
1098
|
<%# ===================================================================== %>
|
|
1079
1099
|
<%# 2. WEB3 — the wallet + on-chain modals, in the order a player meets them. %>
|
|
1080
|
-
<%# Gated by :web3; off = disabled-but-present-yet-openable.
|
|
1100
|
+
<%# Gated by :web3; off = disabled-but-present-yet-openable. The three
|
|
1101
|
+
gem-backed cards carry a second gate, web3_card_off, because a preview
|
|
1102
|
+
you can open needs something registered to open. %>
|
|
1081
1103
|
<%# ===================================================================== %>
|
|
1082
1104
|
<section id="modals-web3" class="space-y-5">
|
|
1083
1105
|
<div class="space-y-1">
|
|
@@ -1094,6 +1116,12 @@
|
|
|
1094
1116
|
<code class="font-mono text-2xs">Studio.feature?(:web3)</code>. Web3 is
|
|
1095
1117
|
<strong><%= web3_on ? "on" : "off" %></strong> here, so these render
|
|
1096
1118
|
<%= web3_on ? "live." : "greyed and badged — but STILL openable as a preview." %>
|
|
1119
|
+
<% unless web3_gem %>
|
|
1120
|
+
<strong>This app bundles no solana-studio</strong>, so the three cards it
|
|
1121
|
+
backs — Connect wallet and the two Sign Wallet states — are
|
|
1122
|
+
listed for reference but <strong>cannot be opened here</strong>. Nothing
|
|
1123
|
+
registers them, so a trigger would open an empty panel.
|
|
1124
|
+
<% end %>
|
|
1097
1125
|
The cards run in the order a player meets them — <strong>get a wallet,
|
|
1098
1126
|
prove it, spend from it</strong>: Connect wallet → Setup Wallet →
|
|
1099
1127
|
the two Sign Wallet states → Processing → success or error →
|
|
@@ -1143,7 +1171,7 @@
|
|
|
1143
1171
|
reference: %(the Web3 "Connect wallet" picker (solana-studio solana_studio/modals/_wallet_connect, configured by style/modals/_wallet_connect) — the walk's entry: picking a wallet runs a brief connecting state then swaps to the on-chain Processing modal. Open with $store.dsModals.open('wallet-connect')),
|
|
1144
1172
|
open_expr: "$store.dsModals.open('wallet-connect')",
|
|
1145
1173
|
glow_when: ds_glow.call("wallet-connect"),
|
|
1146
|
-
disabled:
|
|
1174
|
+
disabled: web3_card_off, openable: web3_gem } do %>
|
|
1147
1175
|
<%# A PICKER: brand tiles with names beside them, which is what this modal
|
|
1148
1176
|
actually is. Two empty outlines said "form with two inputs" and were
|
|
1149
1177
|
the least informative sketch in the section — the one card whose
|
|
@@ -1209,7 +1237,7 @@
|
|
|
1209
1237
|
reference: %(the Web3 "Sign Wallet" card (solana-studio solana_studio/modals/_web3_step_up — the REAL shared partial, not a specimen copy) — shown to an account that holds a self-custody wallet but authenticated this session with a web2 credential. One wallet row, glowing, because it is the only thing to press. Open with $store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })),
|
|
1210
1238
|
open_expr: "$store.dsModals.open('web3-step-up', { provider: 'phantom', providerLabel: 'Phantom', walletHint: '7xKp…JZ2Q' })",
|
|
1211
1239
|
glow_when: ds_glow.call("web3-step-up", provider: true),
|
|
1212
|
-
disabled:
|
|
1240
|
+
disabled: web3_card_off, openable: web3_gem } do %>
|
|
1213
1241
|
<%# A FILLED row: brand tile, the wallet's name, an Installed badge. The
|
|
1214
1242
|
two step-up thumbnails used to differ by a single border-dashed class
|
|
1215
1243
|
on a 20px row, which is not a difference anyone can see at thumbnail
|
|
@@ -1232,7 +1260,7 @@
|
|
|
1232
1260
|
reference: %(the Web3 "Sign Wallet" card with NO remembered brand (solana-studio solana_studio/modals/_web3_step_up) — every wallet linked before a host recorded brands lands here, so it is a live population and not a defensive branch. The row falls back to the picker rather than dead-ending. Open with $store.dsModals.open('web3-step-up', {})),
|
|
1233
1261
|
open_expr: "$store.dsModals.open('web3-step-up', {})",
|
|
1234
1262
|
glow_when: ds_glow.call("web3-step-up", provider: false),
|
|
1235
|
-
disabled:
|
|
1263
|
+
disabled: web3_card_off, openable: web3_gem } do %>
|
|
1236
1264
|
<%# An EMPTY slot: dashed outline, no brand tile, a "?" where the wallet's
|
|
1237
1265
|
name would be. Reads at a glance as "we do not know which wallet",
|
|
1238
1266
|
which is the whole difference between this card and the one before it. %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Reference migration for the Studio::KnowledgeDoc model. Like studio_links,
|
|
2
|
+
# each consumer app installs its own copy into db/migrate
|
|
3
|
+
# (`bin/rails studio_engine:install:migrations && bin/rails db:migrate`) so the
|
|
4
|
+
# table is created in the app's database. Do not hand-copy — a hand copy
|
|
5
|
+
# collides with the installed copy on `class CreateStudioKnowledgeDocs`.
|
|
6
|
+
class CreateStudioKnowledgeDocs < ActiveRecord::Migration[7.2]
|
|
7
|
+
def change
|
|
8
|
+
create_table :studio_knowledge_docs do |t|
|
|
9
|
+
t.string :title, null: false
|
|
10
|
+
# Which business the document belongs to (e.g. "commercial-welding-llc").
|
|
11
|
+
# One app can host several entities' knowledge; every read is entity-scoped.
|
|
12
|
+
t.string :entity, null: false
|
|
13
|
+
# Folder path with no leading/trailing slash ("financials/aging-inventory/2026-08").
|
|
14
|
+
# Folders are implicit — they exist because a document claims the path —
|
|
15
|
+
# and the S3 key mirrors it, so the bucket stays human-browsable.
|
|
16
|
+
t.string :path, null: false, default: ""
|
|
17
|
+
t.string :category
|
|
18
|
+
t.string :mime_type
|
|
19
|
+
# The document's own as-of date (an August aging report dated August),
|
|
20
|
+
# distinct from created_at (when it was uploaded).
|
|
21
|
+
t.date :document_date
|
|
22
|
+
# inbox -> filed -> superseded. Uploads land as inbox for agent triage.
|
|
23
|
+
t.string :status, null: false, default: "inbox"
|
|
24
|
+
# Per-agent access map: {"samson" => "full", "dawn" => "aware"}.
|
|
25
|
+
# "aware" = the agent knows the document exists and gets the safe summary,
|
|
26
|
+
# not the contents; absent agents default to "none".
|
|
27
|
+
t.jsonb :access, null: false, default: {}
|
|
28
|
+
t.jsonb :tags, null: false, default: []
|
|
29
|
+
# The safe summary — what an "aware" agent may read and repeat.
|
|
30
|
+
t.text :summary
|
|
31
|
+
# Provenance: who/where the document came from ("broker email 2026-08-30").
|
|
32
|
+
t.string :source_note
|
|
33
|
+
t.string :uploaded_by
|
|
34
|
+
# Object storage pointer (Studio::S3 logical key); nil for a metadata-only
|
|
35
|
+
# record. byte_size is captured at attach time for the index view.
|
|
36
|
+
t.string :s3_key
|
|
37
|
+
t.bigint :byte_size
|
|
38
|
+
# Set on the OLD row when a newer document replaces it.
|
|
39
|
+
t.bigint :superseded_by_id
|
|
40
|
+
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :studio_knowledge_docs, [:entity, :status]
|
|
45
|
+
add_index :studio_knowledge_docs, [:entity, :path]
|
|
46
|
+
add_index :studio_knowledge_docs, :s3_key, unique: true
|
|
47
|
+
add_index :studio_knowledge_docs, :superseded_by_id
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -554,6 +554,14 @@ module Studio
|
|
|
554
554
|
# A trailing slash is added if you leave it off.
|
|
555
555
|
mattr_accessor :s3_key_prefix, default: nil
|
|
556
556
|
|
|
557
|
+
# Knowledge layer — the S3-backed document store + /admin/knowledge browser
|
|
558
|
+
# (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
|
|
559
|
+
# knowledge_agents is the roster of agent slugs the intake UI offers
|
|
560
|
+
# per-agent access selects for (e.g. %w[samson dawn] on Industries); the
|
|
561
|
+
# access map itself accepts any agent slug regardless.
|
|
562
|
+
mattr_accessor :draw_knowledge_routes, default: false
|
|
563
|
+
mattr_accessor :knowledge_agents, default: []
|
|
564
|
+
|
|
557
565
|
class S3ConfigError < StandardError; end
|
|
558
566
|
|
|
559
567
|
# Whether to validate the host app's User model at boot. See docs/USER_CONTRACT.md.
|
|
@@ -846,6 +854,19 @@ module Studio
|
|
|
846
854
|
constraints: { token: %r{[^/]+} }
|
|
847
855
|
end
|
|
848
856
|
|
|
857
|
+
# Knowledge layer — /admin/knowledge (Studio::KnowledgeDoc): folder/flat
|
|
858
|
+
# document browser, upload-to-inbox intake, per-agent access map, and
|
|
859
|
+
# 15-minute presigned downloads. Opt-in (default off) like every route
|
|
860
|
+
# surface, so no app grows an admin page it never asked for.
|
|
861
|
+
if Studio.draw_knowledge_routes
|
|
862
|
+
get "admin/knowledge", to: "studio/knowledge_docs#index", as: :admin_knowledge
|
|
863
|
+
post "admin/knowledge", to: "studio/knowledge_docs#create"
|
|
864
|
+
get "admin/knowledge/:id", to: "studio/knowledge_docs#show", as: :admin_knowledge_doc
|
|
865
|
+
patch "admin/knowledge/:id", to: "studio/knowledge_docs#update"
|
|
866
|
+
get "admin/knowledge/:id/download", to: "studio/knowledge_docs#download",
|
|
867
|
+
as: :admin_knowledge_doc_download
|
|
868
|
+
end
|
|
869
|
+
|
|
849
870
|
# Solana / Phantom wallet sign-in (nonce challenge + signature verify),
|
|
850
871
|
# plus the MOBILE deep-link callback Phantom redirects back to. The
|
|
851
872
|
# callback used to be listed here as app-specific and is not any more —
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.67.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -328,6 +328,7 @@ files:
|
|
|
328
328
|
- app/controllers/studio/email_images_controller.rb
|
|
329
329
|
- app/controllers/studio/emails_controller.rb
|
|
330
330
|
- app/controllers/studio/geo_settings_controller.rb
|
|
331
|
+
- app/controllers/studio/knowledge_docs_controller.rb
|
|
331
332
|
- app/controllers/studio/links_controller.rb
|
|
332
333
|
- app/controllers/studio/local_emails_controller.rb
|
|
333
334
|
- app/controllers/studio/local_reviews_controller.rb
|
|
@@ -361,6 +362,7 @@ files:
|
|
|
361
362
|
- app/models/studio/email_setting.rb
|
|
362
363
|
- app/models/studio/enumeral.rb
|
|
363
364
|
- app/models/studio/geo_setting.rb
|
|
365
|
+
- app/models/studio/knowledge_doc.rb
|
|
364
366
|
- app/models/studio/link.rb
|
|
365
367
|
- app/models/studio/model_page.rb
|
|
366
368
|
- app/models/theme_setting.rb
|
|
@@ -436,6 +438,12 @@ files:
|
|
|
436
438
|
- app/views/studio/emails/show.html.erb
|
|
437
439
|
- app/views/studio/fields/_date_of_birth.html.erb
|
|
438
440
|
- app/views/studio/geo_settings/edit.html.erb
|
|
441
|
+
- app/views/studio/knowledge_docs/_access_chips.html.erb
|
|
442
|
+
- app/views/studio/knowledge_docs/_browser.html.erb
|
|
443
|
+
- app/views/studio/knowledge_docs/_doc_row.html.erb
|
|
444
|
+
- app/views/studio/knowledge_docs/_upload_form.html.erb
|
|
445
|
+
- app/views/studio/knowledge_docs/index.html.erb
|
|
446
|
+
- app/views/studio/knowledge_docs/show.html.erb
|
|
439
447
|
- app/views/studio/links/confirm.html.erb
|
|
440
448
|
- app/views/studio/local_emails/index.html.erb
|
|
441
449
|
- app/views/studio/mailers/_layered_banner.html.erb
|
|
@@ -544,6 +552,7 @@ files:
|
|
|
544
552
|
- db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
|
|
545
553
|
- db/migrate/20260813220000_add_standard_user_profile_columns.rb
|
|
546
554
|
- db/migrate/20260818120000_create_studio_geo_settings.rb
|
|
555
|
+
- db/migrate/20260901000001_create_studio_knowledge_docs.rb
|
|
547
556
|
- lib/studio-engine.rb
|
|
548
557
|
- lib/studio.rb
|
|
549
558
|
- lib/studio/cable.rb
|