feat: refine metadata catalog workflows
This commit is contained in:
+62
-21
@@ -244,6 +244,29 @@ incompatibile o non aggiornato produce nessuna Evidence e un avviso esplicito. I
|
|||||||
workflow può continuare, ma non usa mai silenziosamente contenuti di una revisione
|
workflow può continuare, ma non usa mai silenziosamente contenuti di una revisione
|
||||||
precedente o di un altro workspace.
|
precedente o di un altro workspace.
|
||||||
|
|
||||||
|
## Configurazione dei modelli
|
||||||
|
|
||||||
|
**Workspace Descriptor** — La dichiarazione versionata dell'identità e dello scope del
|
||||||
|
Workspace Database e delle Evidence di un workspace. Non definisce modelli, selezioni di
|
||||||
|
modello o Database Binding specifiche di un'installazione.
|
||||||
|
|
||||||
|
**Installation Model Catalog** — L'insieme dichiarativo, proprio di un'installazione, dei
|
||||||
|
modelli disponibili, dei loro Model Usage e dei relativi default. È l'unica autorità per i
|
||||||
|
modelli di sessione, generazione dei metadati ed embedding e non appartiene a un workspace.
|
||||||
|
_Avoid_: Model Catalog, Metadata Generation Model Configuration
|
||||||
|
|
||||||
|
**Model Usage** — Lo scopo per cui un modello dell'Installation Model Catalog può essere
|
||||||
|
usato: `session`, `metadata_generation` oppure `embedding`. L'ammissibilità e il default
|
||||||
|
dipendono dall'uso, non dal workspace.
|
||||||
|
|
||||||
|
**Model Selection** — La scelta runtime, a livello di installazione, di un modello del
|
||||||
|
catalogo per uno specifico Model Usage. Riferisce l'identità canonica del modello senza
|
||||||
|
ridefinirne provider, endpoint o capacità.
|
||||||
|
|
||||||
|
**Model Runtime Projection** — La rappresentazione derivata e non autoritativa
|
||||||
|
dell'Installation Model Catalog richiesta da uno specifico runtime. Può essere rigenerata
|
||||||
|
integralmente dalla configurazione dell'installazione.
|
||||||
|
|
||||||
## Catalogo dei metadati
|
## Catalogo dei metadati
|
||||||
|
|
||||||
**Workspace Database** — Il database che appartiene a un solo workspace e non può essere
|
**Workspace Database** — Il database che appartiene a un solo workspace e non può essere
|
||||||
@@ -385,38 +408,56 @@ con la binding corrente.
|
|||||||
distinti dai fatti strutturali governati dalla sincronizzazione. Possono essere popolati dall'AI,
|
distinti dai fatti strutturali governati dalla sincronizzazione. Possono essere popolati dall'AI,
|
||||||
da un'importazione o da una modifica amministrativa senza cambiare il database esterno.
|
da un'importazione o da una modifica amministrativa senza cambiare il database esterno.
|
||||||
|
|
||||||
**Metadata Generation Model Configuration** — La configurazione a livello di setup applicativo
|
|
||||||
che elenca i modelli selezionabili, il default e i riferimenti agli eventuali segreti per la sola
|
|
||||||
generazione dei metadati. Un modello keyless è ammesso solo con un endpoint esplicito che non
|
|
||||||
richiede autenticazione. Non appartiene al workspace ed è indipendente dalla configurazione Pi.
|
|
||||||
|
|
||||||
**Model Completion Helper** — Il processo Python interno ed effimero che esegue una singola
|
**Model Completion Helper** — Il processo Python interno ed effimero che esegue una singola
|
||||||
richiesta LiteLLM per conto del backend. Non è un servizio HTTP, non possiede il lifecycle della
|
richiesta LiteLLM per conto del backend. Non è un servizio HTTP, non possiede il lifecycle della
|
||||||
Description Generation Run e non è una CLI esposta agli utenti.
|
Description Generation Run e non è una CLI esposta agli utenti.
|
||||||
|
|
||||||
**Catalog Sample** — Un input transitorio composto da un massimo di cinque righe e da valori di
|
**Catalog Sample** — Un input transitorio composto da un massimo di cinque righe e da valori di
|
||||||
esempio bounded di una Catalog Table per la generazione delle descrizioni. Può contenere valori
|
esempio bounded di una Catalog Table per la generazione delle descrizioni. Può contenere valori
|
||||||
reali oppure sintetici in base al Sensitive Data Flag della Catalog Column; non viene persistito
|
reali oppure sintetici in base alla Source Value Disclosure Decision; non viene persistito e non
|
||||||
e non diventa Catalog Metadata.
|
diventa Catalog Metadata.
|
||||||
|
|
||||||
**Sensitive Data Flag** — La scelta binaria umana applicata a una Catalog Column: `true` protegge
|
**Sensitive Data Flag** — La classificazione binaria umana applicata a una Catalog Column. Può
|
||||||
i valori sorgente e `false` ne consente l'invio al modello. Il valore predefinito è `false`, anche
|
essere impostata liberamente dall'amministratore anche in contrasto con una valutazione automatica.
|
||||||
per le nuove colonne.
|
|
||||||
|
|
||||||
**Sensitive Data Policy** — La regola che applica il Sensitive Data Flag ai Catalog Sample:
|
**Local Sensitivity Assessment** — La valutazione locale, non autoritativa e priva di LLM di una
|
||||||
valori sintetici per una colonna protetta, valori reali per una colonna non protetta. L'AI può
|
Catalog Column, basata su metadati e contenuto sorgente, con esito `sensitive`, `non_sensitive`
|
||||||
suggerire il flag dai soli metadati tecnici di un database, delle tabelle o delle colonne
|
oppure `unknown`.
|
||||||
esplicitamente selezionate; le richieste ampie vengono divise in batch bounded, ma soltanto
|
_Avoid_: AI suggestion, automatic flag
|
||||||
l'utente imposta i flag dopo aver rivisto la proposta completa.
|
|
||||||
|
**Local NER Detector** — Il componente NLP opzionale e CPU-only che esamina soltanto testo ancora
|
||||||
|
ambiguo e restituisce evidenze al Local Sensitivity Assessment. Non decide lo stato della colonna,
|
||||||
|
non usa un LLM generativo e non persiste valori sorgente.
|
||||||
|
_Avoid_: AI classifier, local LLM fallback
|
||||||
|
|
||||||
|
**Model Data Boundary** — La qualificazione amministrativa di un modello come `internal` oppure
|
||||||
|
`external` rispetto al confine entro cui i valori sorgente possono essere comunicati.
|
||||||
|
_Avoid_: local model, remote model
|
||||||
|
|
||||||
|
**Source Value Disclosure Decision** — L'unica decisione effettiva che stabilisce se un modello
|
||||||
|
riceve valori sorgente reali oppure sostituti sintetici, combinando Model Data Boundary e Sensitive
|
||||||
|
Data Flag.
|
||||||
|
_Avoid_: sample filter, export flag
|
||||||
|
|
||||||
|
**Sensitive Data Policy** — L'insieme versionato di regole locali generali e specifiche che produce
|
||||||
|
una Local Sensitivity Assessment. Un singolo riscontro blocca l'intera colonna e qualsiasi valore
|
||||||
|
testuale più lungo di 500 caratteri rende sensibile la colonna.
|
||||||
_Avoid_: PII filter, sample filter
|
_Avoid_: PII filter, sample filter
|
||||||
|
|
||||||
**Sensitive Data Suggestion Run** — Il tentativo amministrativo tracciato con cui il modello
|
**Sensitivity Analysis Run** — Il tentativo amministrativo esplicito e tracciato che valuta una
|
||||||
propone Sensitive Data Flag dai soli metadati strutturali. Conserva stato e conteggi aggregati,
|
selezione di colonne mediante la Sensitive Data Policy. Conserva stato, copertura e conteggi
|
||||||
ma non i suggerimenti per colonna, che restano una proposta transitoria fino al salvataggio umano.
|
aggregati, ma non valori sorgente né esiti per colonna.
|
||||||
|
_Avoid_: Sensitive Data Suggestion Run, AI analysis
|
||||||
|
|
||||||
**Sensitive Data Suggestion Event** — Una riga testuale ordinata e sanitizzata che registra
|
**Sensitivity Review Draft** — La proposta transitoria che associa alle colonne selezionate una
|
||||||
l'avvio, l'esito o l'errore di una Sensitive Data Suggestion Run senza conservare prompt,
|
Local Sensitivity Assessment e le relative evidenze sanificate. Non modifica il Sensitive Data Flag
|
||||||
risposte grezze del provider o proposte per colonna.
|
finché l'amministratore non salva le proprie decisioni e viene scartata al reload.
|
||||||
|
_Avoid_: automatic flag
|
||||||
|
|
||||||
|
**Sensitivity Analysis Event** — Una riga testuale ordinata e sanificata che registra l'avvio,
|
||||||
|
l'esito o l'errore di una Sensitivity Analysis Run senza conservare contenuti sorgente, output grezzi
|
||||||
|
del detector o proposte per colonna.
|
||||||
|
_Avoid_: Sensitive Data Suggestion Event
|
||||||
|
|
||||||
**Introspection Capability** — Una categoria di struttura fisica che una Database Binding
|
**Introspection Capability** — Una categoria di struttura fisica che una Database Binding
|
||||||
può osservare, come tabelle, colonne, relazioni, indici o enum. Una capability non disponibile
|
può osservare, come tabelle, colonne, relazioni, indici o enum. Una capability non disponibile
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Use one Installation Model Catalog with runtime projections
|
||||||
|
|
||||||
|
ThothII currently declares model availability independently in installation
|
||||||
|
`metadataGeneration`, Pi configuration, and workspace `llm_policy` and embedding settings. The
|
||||||
|
Installation Model Catalog in `thothii-installation.yaml` becomes the sole authored authority for
|
||||||
|
session, metadata-generation, and embedding models, including their allowed usages and per-usage
|
||||||
|
defaults. Workspace descriptors retain database identity and scope plus Evidence concerns, but no
|
||||||
|
model policy or selection; installation-local Database Bindings remain separate from them.
|
||||||
|
|
||||||
|
Pi, the backend metadata-generation helper, and the embedding runtime consume generated Model
|
||||||
|
Runtime Projections of that catalog. Runtime Model Selection stores only a canonical catalog model
|
||||||
|
identity and use-specific controls such as thinking level; endpoint, provider, capabilities, and
|
||||||
|
credential references remain catalog facts, while secret values remain in protected secret stores.
|
||||||
|
The metadata-generation helper continues to use LiteLLM independently of Pi, as established by
|
||||||
|
ADR-0009: unifying model declaration does not unify execution lifecycles.
|
||||||
|
|
||||||
|
The migration is intentionally fail-closed. Workspace schema v4 removes `llm_policy` and the
|
||||||
|
entire redundant `semantic_index`; collection identity is derived from the workspace identity,
|
||||||
|
while vector-store and embedding facts come from the installation. A deterministic migration
|
||||||
|
rewrites existing descriptors. The
|
||||||
|
installation loader replaces `metadataGeneration` with `modelCatalog` and rejects the legacy form
|
||||||
|
with an actionable migration error rather than keeping two live sources. Existing Pi
|
||||||
|
`models.json` and enabled-model settings become generated artifacts and are never edited as
|
||||||
|
authoritative configuration.
|
||||||
|
|
||||||
|
Catalog identities use the canonical `provider/model` form; runtime-specific upstream names are
|
||||||
|
adapter facts, not additional ThothII identities. Installation descriptors use schema version 2
|
||||||
|
and model-free workspace descriptors use schema version 4. Removing a model never substitutes it
|
||||||
|
inside an existing session: an unresolvable resume fails explicitly. Published semantic indexes
|
||||||
|
record the embedding identity and dimensions that produced them and require explicit
|
||||||
|
reprocessing when those facts change.
|
||||||
|
|
||||||
|
Model eligibility is expressed by the presence of a `session` or `metadataGeneration` block,
|
||||||
|
without a duplicate usages list. The installation declares one active embedding identity and its
|
||||||
|
dimensions rather than a selectable embedding catalog. Runtime projections are regenerated
|
||||||
|
deterministically and atomically at start, so they require no persisted digest and are excluded
|
||||||
|
from installation backups; restore regenerates them from the validated installation descriptor.
|
||||||
|
|
||||||
|
A provider owns one endpoint, one explicit authentication mode, and only the runtime adapters it
|
||||||
|
needs. Authentication is either a protected secret-environment reference, Pi-owned authentication
|
||||||
|
for session-only built-in models, or explicit keyless operation for an explicit endpoint; models
|
||||||
|
cannot override it. Pi built-in model facts are not copied into the installation. A session default
|
||||||
|
and the single embedding definition are required, while metadata generation and its default may be
|
||||||
|
omitted together. The schema deliberately excludes unused abstractions and future properties until
|
||||||
|
runtime behavior requires them.
|
||||||
|
|
||||||
|
The catalog session default replaces `PI_PROVIDER`, `PI_MODEL`, and persisted installation model
|
||||||
|
defaults as configuration sources. A user or session selection is only a canonical catalog
|
||||||
|
reference, and an existing session keeps that reference without silently switching models. A
|
||||||
|
generated Compose projection supplies the catalog-derived embedding values and projection mounts
|
||||||
|
to every affected service, so neither the base Compose files nor `operator.env` repeat model facts.
|
||||||
|
|
||||||
|
Workspace v3-to-v4 migration is a deterministic removal of `llm_policy` and `semantic_index`.
|
||||||
|
Installation migration instead inspects the legacy installation metadata block and both Pi model
|
||||||
|
files: it emits a v2 candidate only when their identities and settings can be reconciled without
|
||||||
|
guessing. Conflicts produce an actionable report and leave every source untouched.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
- A separate catalog file referenced by the installation was rejected because it adds path,
|
||||||
|
permission, backup, and atomic-update coordination without a current need for cross-installation
|
||||||
|
sharing.
|
||||||
|
- Pi `models.json` was rejected as the authority because it is a Pi-specific projection that does
|
||||||
|
not express all ThothII usages, built-in providers, metadata-generation controls, or embedding
|
||||||
|
facts.
|
||||||
|
- Transitional dual reading was rejected because it would preserve the configuration discrepancy
|
||||||
|
this decision is intended to eliminate.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
---
|
||||||
|
|
||||||
|
# Assess sensitive columns locally from source content
|
||||||
|
|
||||||
|
The Sensitive Data Flag remains a human-owned boolean. An explicit, selection-scoped sensitivity
|
||||||
|
analysis may propose changes by inspecting both catalog metadata and source values, but it never
|
||||||
|
writes the flag. The administrator may accept, reject, or reverse every proposal.
|
||||||
|
|
||||||
|
One TypeScript `SensitivityClassifier` is the only component allowed to produce the column-level
|
||||||
|
assessment `sensitive`, `non_sensitive`, or `unknown`. It applies a versioned Sensitive Data Policy
|
||||||
|
and consumes values through database-independent streaming adapters. Database-specific code may
|
||||||
|
read and normalize bounded values, but it may not decide sensitivity.
|
||||||
|
|
||||||
|
The classifier first applies deterministic metadata rules, value validators, checksums,
|
||||||
|
dictionaries, and length rules. A single validated sensitive match makes the whole column
|
||||||
|
`sensitive`; any textual value longer than 500 characters is such a match. It attempts a complete
|
||||||
|
scan, but after five seconds per table it continues by sampling within the remaining run budget. A
|
||||||
|
completed scan with no finding may produce `non_sensitive`; an incomplete scan with no finding
|
||||||
|
produces `unknown`.
|
||||||
|
|
||||||
|
Ambiguous text may additionally be sent to an optional local NER detector only while time remains.
|
||||||
|
The detector runs on CPU, receives no tools or network access, does not persist source values, and
|
||||||
|
returns evidence rather than the column decision. The initial supported detector is
|
||||||
|
[`fastino/gliner2-privacy-filter-PII-multi`](https://huggingface.co/fastino/gliner2-privacy-filter-PII-multi),
|
||||||
|
used through the Apache-2.0 GLiNER2 Python library with a pinned model revision. Its model weights
|
||||||
|
and GLiNER2 code are Apache-2.0, and its mDeBERTa base model is MIT. It is trained for seven
|
||||||
|
languages including Italian and can run on CPU without using the installation's GPUs.
|
||||||
|
|
||||||
|
The NER detector is an optional installation asset because its weights and runtime are materially
|
||||||
|
larger than the deterministic TypeScript engine. It is invoked only for otherwise unresolved text,
|
||||||
|
never for values already classified by a decisive rule. If it is disabled, unavailable, times out,
|
||||||
|
or returns no qualifying evidence before the deadline, the classifier follows the same coverage
|
||||||
|
rule and may return `unknown`.
|
||||||
|
|
||||||
|
No generative LLM, internal or external, participates in sensitivity assessment. A fallback to an
|
||||||
|
installation-local LLM is unnecessary while a permissively licensed local NER implementation is
|
||||||
|
available, and would reintroduce queue latency, non-deterministic judgments, prompt-injection
|
||||||
|
surface, and contention with normal inference. Introducing such a fallback would require a new
|
||||||
|
decision based on evidence that the NER path is unusable.
|
||||||
|
|
||||||
|
This decision supersedes ADR-0011 only where that ADR assigns draft sensitivity suggestions to an
|
||||||
|
AI using structural metadata. ADR-0011's human authority, transient draft, explicit scope, and
|
||||||
|
Sensitive Data Flag remain in force. How description generation uses the flag is outside this
|
||||||
|
decision.
|
||||||
|
|
||||||
|
The selected model's published evaluation is not an Italian production acceptance test. Before
|
||||||
|
enabling the NER profile by default, ThothII must pin the artifacts, generate a dependency/license
|
||||||
|
inventory, and pass a CPU benchmark plus a labeled Italian corpus representative of the target
|
||||||
|
databases. Failure of those gates disables NER; it does not silently select another model or an
|
||||||
|
LLM.
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# Installation Model Catalog
|
||||||
|
|
||||||
|
Status: accepted design; implementation not started.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
`thothii-installation.yaml` is the only operator-authored source for models used by interactive
|
||||||
|
sessions, metadata generation, and embedding. Runtime-specific files are deterministic projections,
|
||||||
|
not additional configuration sources. Workspace descriptors contain database and Evidence concerns
|
||||||
|
and no model, provider, allowlist, default, embedding, or vector-store configuration.
|
||||||
|
|
||||||
|
This design does not merge execution lifecycles. Pi continues to run interactive sessions, the
|
||||||
|
short-lived LiteLLM helper continues to perform metadata generation, and the internal Ollama service
|
||||||
|
continues to provide embeddings. They share model declaration, not execution machinery.
|
||||||
|
|
||||||
|
## Canonical installation shape
|
||||||
|
|
||||||
|
The following example covers all currently required cases: a Pi built-in model, an authenticated
|
||||||
|
custom endpoint, a keyless internal endpoint, metadata generation, and the single embedding model.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schemaVersion: 2
|
||||||
|
profile: server
|
||||||
|
projectDirectory: /srv/thothii
|
||||||
|
envFile: /srv/thothii/operator.env
|
||||||
|
|
||||||
|
workspaceRepository:
|
||||||
|
remote: git@git.example.com:organization/workspaces.git
|
||||||
|
branch: main
|
||||||
|
access: ssh
|
||||||
|
|
||||||
|
modelCatalog:
|
||||||
|
defaults:
|
||||||
|
session: zai/glm-5.3
|
||||||
|
metadataGeneration: local-qwen/qwen3.6-35b-a3b
|
||||||
|
|
||||||
|
embedding:
|
||||||
|
id: ollama/qwen3-embedding:0.6b
|
||||||
|
dimensions: 1024
|
||||||
|
|
||||||
|
providers:
|
||||||
|
deepseek:
|
||||||
|
authentication:
|
||||||
|
mode: pi_auth
|
||||||
|
session:
|
||||||
|
mode: pi_builtin
|
||||||
|
models:
|
||||||
|
deepseek-v4-pro:
|
||||||
|
session: {}
|
||||||
|
deepseek-v4-flash:
|
||||||
|
session: {}
|
||||||
|
|
||||||
|
zai:
|
||||||
|
endpoint:
|
||||||
|
baseUrl: https://api.z.ai/api/coding/paas/v4
|
||||||
|
authentication:
|
||||||
|
mode: secret_env
|
||||||
|
apiKeyEnv: ZAI_API_KEY
|
||||||
|
session:
|
||||||
|
mode: openai_compatible
|
||||||
|
metadataGeneration:
|
||||||
|
litellmProvider: openai
|
||||||
|
models:
|
||||||
|
glm-5.3:
|
||||||
|
label: GLM-5.3
|
||||||
|
session:
|
||||||
|
reasoning: true
|
||||||
|
contextWindow: 200000
|
||||||
|
maxTokens: 131072
|
||||||
|
metadataGeneration: {}
|
||||||
|
|
||||||
|
local-qwen:
|
||||||
|
endpoint:
|
||||||
|
baseUrl: https://ml-aritmolab.policlinicosandonato.it/v1
|
||||||
|
authentication:
|
||||||
|
mode: none
|
||||||
|
session:
|
||||||
|
mode: openai_compatible
|
||||||
|
metadataGeneration:
|
||||||
|
litellmProvider: openai
|
||||||
|
models:
|
||||||
|
qwen3.6-35b-a3b:
|
||||||
|
label: Qwen3.6 35B A3B
|
||||||
|
session:
|
||||||
|
reasoning: false
|
||||||
|
contextWindow: 131072
|
||||||
|
maxTokens: 16384
|
||||||
|
compatibility:
|
||||||
|
supportsDeveloperRole: false
|
||||||
|
supportsReasoningEffort: false
|
||||||
|
supportsStore: false
|
||||||
|
maxTokensField: max_tokens
|
||||||
|
metadataGeneration:
|
||||||
|
disableThinking: true
|
||||||
|
|
||||||
|
authentication:
|
||||||
|
configDirectory: /srv/thothii/auth-canonical
|
||||||
|
runtimeProjection:
|
||||||
|
directory: /srv/thothii/auth-runtime
|
||||||
|
uid: 10001
|
||||||
|
gid: 10001
|
||||||
|
```
|
||||||
|
|
||||||
|
The catalog uses maps instead of repeated IDs. The canonical identity of a model is always derived
|
||||||
|
as `<provider-key>/<model-key>`. `upstreamModel` may be added to a model only when the endpoint uses
|
||||||
|
a different identifier. `label` is optional and falls back to the canonical identity.
|
||||||
|
|
||||||
|
Model eligibility is not repeated in an `usages` array. A `session` block makes the model eligible
|
||||||
|
for sessions; a `metadataGeneration` block makes it eligible for metadata generation. The embedding
|
||||||
|
is a single required installation value rather than a list plus default.
|
||||||
|
|
||||||
|
## Provider and authentication rules
|
||||||
|
|
||||||
|
A provider owns one endpoint, one authentication mode, and zero or one adapter for each runtime.
|
||||||
|
Model entries cannot override provider endpoint or credentials. If the same upstream service needs
|
||||||
|
different endpoints or credentials, the installation declares two provider identities.
|
||||||
|
|
||||||
|
Supported session modes are intentionally closed:
|
||||||
|
|
||||||
|
- `pi_builtin`: Pi already owns the model's technical descriptor; the model's `session` block is
|
||||||
|
empty and ThothII does not copy context-window or compatibility facts.
|
||||||
|
- `openai_compatible`: ThothII generates a Pi custom-provider descriptor; each session model supplies
|
||||||
|
the technical values required by Pi.
|
||||||
|
|
||||||
|
Metadata generation uses the provider-level `litellmProvider`. A model-level
|
||||||
|
`metadataGeneration.disableThinking: true` is permitted only for an explicit compatible endpoint.
|
||||||
|
There is no generic adapter or plugin abstraction in schema version 2.
|
||||||
|
|
||||||
|
Exactly one provider authentication mode is allowed:
|
||||||
|
|
||||||
|
- `secret_env` requires an approved API-key environment reference present in the protected secret
|
||||||
|
bundle. Secret values never enter YAML, generated files, logs, arguments, or API responses.
|
||||||
|
- `pi_auth` is valid only for session-only `pi_builtin` providers and resolves through Pi's protected
|
||||||
|
authentication projection.
|
||||||
|
- `none` is valid only for an explicit endpoint. Runtime projections may supply a fixed non-secret
|
||||||
|
compatibility placeholder when a client library requires a non-empty key.
|
||||||
|
|
||||||
|
## Defaults and selections
|
||||||
|
|
||||||
|
`defaults.session` and `embedding` are required. `defaults.metadataGeneration` is required exactly
|
||||||
|
when at least one model has a `metadataGeneration` block; metadata generation may otherwise be
|
||||||
|
absent and its UI controls are disabled.
|
||||||
|
|
||||||
|
`modelCatalog.defaults.session` is the only configured session-model default. `PI_PROVIDER`,
|
||||||
|
`PI_MODEL`, and provider/model fields in installation-default settings are removed. A user choice is
|
||||||
|
a Model Selection containing only the canonical model identity and runtime controls such as thinking
|
||||||
|
level. A session manifest pins the selected canonical identity.
|
||||||
|
|
||||||
|
Removing the currently selected model causes new-session selection to fall back to the catalog
|
||||||
|
default with an explicit administrative warning. An existing session is never silently moved to a
|
||||||
|
different model; resume fails with `model_unavailable` when its pinned identity can no longer be
|
||||||
|
resolved.
|
||||||
|
|
||||||
|
## Generated runtime projections
|
||||||
|
|
||||||
|
Before Compose starts, `tht` strictly validates schema version 2 and generates installation-local
|
||||||
|
artifacts below `deploy/<installation-id>/generated/`:
|
||||||
|
|
||||||
|
- a normalized catalog JSON consumed defensively by the backend;
|
||||||
|
- Pi `models.json` for custom providers;
|
||||||
|
- Pi `settings.json`, combining fixed product settings with the session-eligible canonical IDs;
|
||||||
|
- a Compose override that mounts the projections and supplies embedding identity and dimensions to
|
||||||
|
core, preprocessing, and `embedding-model-init`.
|
||||||
|
|
||||||
|
Generation is deterministic and published only after every candidate artifact validates. A failed
|
||||||
|
generation aborts start before Compose is invoked. `tht doctor` recomputes expected bytes and reports
|
||||||
|
differences; no digest manifest or separate apply command exists. When projection bytes change,
|
||||||
|
`tht start` recreates the affected services so they cannot continue with an older bind mount.
|
||||||
|
|
||||||
|
Generated projections are not backed up. Restore validates the canonical installation descriptor,
|
||||||
|
regenerates every projection, and only then starts services. Base Compose files and `operator.env`
|
||||||
|
must contain no model identities, defaults, endpoints, or dimensions.
|
||||||
|
|
||||||
|
## Workspace schema v4
|
||||||
|
|
||||||
|
Workspace schema v4 removes both top-level `llm_policy` and `semantic_index`. The entire latter
|
||||||
|
block is redundant today: its engine and distance are product constants, its collection duplicates
|
||||||
|
the workspace ID, and its model and dimensions are installation facts.
|
||||||
|
|
||||||
|
The runtime derives:
|
||||||
|
|
||||||
|
- Qdrant collection identity from the workspace ID;
|
||||||
|
- engine and distance from the supported product contract;
|
||||||
|
- embedding identity and dimensions from the Installation Model Catalog.
|
||||||
|
|
||||||
|
The published index generation records the canonical embedding identity and dimensions that created
|
||||||
|
it. A mismatch makes the index explicitly incompatible and requires operator-triggered
|
||||||
|
preprocessing. No existing index is deleted or rebuilt automatically.
|
||||||
|
|
||||||
|
The v3-to-v4 workspace migration is deterministic: set `workspace.schema_version` to `4`, remove
|
||||||
|
`llm_policy`, and remove `semantic_index`. It does not alter database, Evidence, diagnostics, or
|
||||||
|
binding data.
|
||||||
|
|
||||||
|
## Installation migration
|
||||||
|
|
||||||
|
Legacy installation migration must inspect all three former sources:
|
||||||
|
|
||||||
|
1. `metadataGeneration` in `thothii-installation.yaml`;
|
||||||
|
2. `deploy/pi/models.json`;
|
||||||
|
3. `deploy/pi/settings.json`.
|
||||||
|
|
||||||
|
The migrator emits a version-2 candidate only when it can reconcile identities, endpoints,
|
||||||
|
credentials, and runtime-specific facts without guessing. Ambiguous aliases such as `glm-53`,
|
||||||
|
`zai/glm-5.3`, and `openai/glm-5.3` are not silently equated. A conflict produces a field-level
|
||||||
|
report and leaves every input unchanged for operator resolution.
|
||||||
|
|
||||||
|
After migration, the strict loader rejects `metadataGeneration`, workspace `llm_policy`, workspace
|
||||||
|
`semantic_index`, legacy Pi source files, unknown fields, duplicate YAML keys, invalid defaults, and
|
||||||
|
incompatible authentication/adapter combinations with an actionable `migration_required` or
|
||||||
|
validation error.
|
||||||
|
|
||||||
|
## Final simplicity audit
|
||||||
|
|
||||||
|
The accepted design removes every configuration duplication that can be removed without inference:
|
||||||
|
|
||||||
|
- one authored installation file instead of an installation block plus two Pi files;
|
||||||
|
- one canonical `provider/model` identity instead of display IDs and runtime IDs;
|
||||||
|
- per-use blocks instead of a duplicated usages list;
|
||||||
|
- one embedding entry instead of a selectable embedding catalog;
|
||||||
|
- one catalog session default instead of environment and settings defaults;
|
||||||
|
- no model or vector-store fields in workspace descriptors;
|
||||||
|
- provider-level credentials instead of per-model credentials;
|
||||||
|
- no generic runtime-plugin abstraction;
|
||||||
|
- no persisted digest, apply command, or backup of generated projections.
|
||||||
|
|
||||||
|
The remaining generated files are necessary boundary adapters, not configuration concepts. Making
|
||||||
|
the backend parse the authoring YAML independently would remove one file but restore two semantic
|
||||||
|
validators. Hard-coding embedding values in Compose would remove one projection but restore a model
|
||||||
|
source outside the catalog. Inferring authentication from missing fields would save one YAML key but
|
||||||
|
turn a safe explicit choice into ambiguity. These apparent simplifications are therefore rejected.
|
||||||
|
|
||||||
|
No further reduction was found that preserves one authority, strict validation, explicit security,
|
||||||
|
session determinism, and model-free workspaces.
|
||||||
|
|
||||||
|
## Implementation surface
|
||||||
|
|
||||||
|
Implementation must update the host `tht` installation loader, setup and lifecycle projection,
|
||||||
|
doctor, backup/restore, Compose mounts and embedding inputs, backend catalog/settings/session model
|
||||||
|
resolution, workspace schema and migration, runtime rendering and diagnostics, frontend workspace
|
||||||
|
drafts and model filtering, examples, fixtures, and documentation. Existing session manifests remain
|
||||||
|
readable and keep their pinned provider/model identity; only resume resolution changes to the new
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
This document authorizes design only. Software implementation begins only after a separate explicit
|
||||||
|
request.
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# Local sensitive-column classifier: TypeScript or Python
|
||||||
|
|
||||||
|
**Date:** 2026-09-02
|
||||||
|
**Scope:** library/runtime choice for a local classifier without a generative LLM. Synthetic data generation and
|
||||||
|
model-boundary policy for description generation are deliberately out of scope.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Implement the mandatory classifier **inside the TypeScript backend**, with no learned model in its
|
||||||
|
mandatory path. The agreed policy is dominated by deterministic work:
|
||||||
|
type/declared-size rules, bounded string normalization, patterns, checksums, exact dictionaries,
|
||||||
|
context terms, and "one match makes the column sensitive". TypeScript is fully adequate for that
|
||||||
|
work and keeps the classifier in the process which already owns the catalog, source connectors,
|
||||||
|
run lifecycle, cancellation, and the human override.
|
||||||
|
|
||||||
|
Use small, focused dependencies rather than a general NLP framework:
|
||||||
|
|
||||||
|
- `validator` for syntax/checksum-oriented recognizers such as email, IP, MAC, IBAN, BIC, payment
|
||||||
|
cards, and locale-aware Italian tax IDs, passports, identity cards and VAT numbers; its official
|
||||||
|
API exposes these validators individually, so unused validators need not be imported
|
||||||
|
([validator.js source and API](https://github.com/validatorjs/validator.js/)).
|
||||||
|
- `libphonenumber-js/max` for international telephone parsing and digit-pattern validation. The
|
||||||
|
project's `max` metadata is intentionally more precise than its default/minimal metadata
|
||||||
|
([libphonenumber-js validation documentation](https://github.com/catamphetamine/libphonenumber-js/blob/master/README.md#isvalid-boolean)).
|
||||||
|
- ThothII-owned recognizers for identifiers not covered adequately by a selected validator (for
|
||||||
|
example the Italian driving licence) and organization-specific identifiers, each with a version,
|
||||||
|
tests, context words, and checksum/semantic validation where the identifier defines one.
|
||||||
|
- A safe regular-expression boundary for administrator-authored rules. `node-re2` offers a mostly
|
||||||
|
`RegExp`-compatible, ReDoS-resistant engine and a set API for matching many patterns, but is a
|
||||||
|
native addon that downloads or builds a binary during installation. That packaging cost must be
|
||||||
|
tested against the project images before adoption
|
||||||
|
([node-re2 README](https://github.com/uhop/node-re2/blob/master/README.md)). If arbitrary regular
|
||||||
|
expressions are not exposed, ThothII can instead keep a reviewed built-in pattern set and bound
|
||||||
|
every inspected value.
|
||||||
|
|
||||||
|
Do **not** add NLP.js, spaCy, Presidio, or a transformer merely to implement regexes and dictionaries.
|
||||||
|
They do not make deterministic identifiers more semantically understandable. In particular, NLP.js
|
||||||
|
documents useful Italian tokenization plus enum/regex/built-in entities, but its advertised built-ins
|
||||||
|
are mostly the same formatted values already covered above; it does not advertise a ready-made
|
||||||
|
Italian PII model for arbitrary people or clinical concepts
|
||||||
|
([NLP.js official README](https://github.com/axa-group/nlp.js/)).
|
||||||
|
|
||||||
|
Add an **optional, non-generative statistical NER pack** based on
|
||||||
|
[`fastino/gliner2-privacy-filter-PII-multi`](https://huggingface.co/fastino/gliner2-privacy-filter-PII-multi).
|
||||||
|
The selected model and its GLiNER2 runtime are Apache-2.0, its mDeBERTa base is MIT, and the model
|
||||||
|
is trained across seven languages including Italian. Run the official implementation in one warmed,
|
||||||
|
CPU-only Python worker; do not spawn it once per column or table. It returns entity evidence only.
|
||||||
|
The TypeScript classifier remains the sole owner of the final column assessment and invokes NER
|
||||||
|
only for bounded text that deterministic rules did not resolve and only while the scan deadline has
|
||||||
|
time remaining.
|
||||||
|
|
||||||
|
This is a positive Q29 decision, not a placeholder for later library selection. The production gate
|
||||||
|
is limited to pinning artifacts, producing an SBOM/license inventory, and measuring the selected
|
||||||
|
model on PSD fixtures and target CPUs. If the optional pack is absent or misses its deadline, the
|
||||||
|
normal coverage rule produces `unknown`; ThothII does not substitute a generative LLM.
|
||||||
|
|
||||||
|
## Why the TypeScript baseline is sufficient
|
||||||
|
|
||||||
|
The classifier is not being asked to infer an open-ended privacy judgment from prose. It executes a
|
||||||
|
versioned policy and produces evidence. Its mandatory recognizers can be expressed as:
|
||||||
|
|
||||||
|
| Rule family | Implementation | Model needed? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| declared `text`/CLOB/unbounded string or declared maximum `>500` | catalog metadata rule | no |
|
||||||
|
| an observed value longer than 500 characters | length rule, ideally detected in SQL before transferring the value | no |
|
||||||
|
| email, IP/MAC, URL, UUID, payment card, IBAN/BIC, phone | pattern plus format/checksum validator | no |
|
||||||
|
| Italian fiscal code/VAT/passport/identity card/driving licence | locale-aware validator where available, otherwise a reviewed country recognizer; pattern, context and checksum where defined | no |
|
||||||
|
| credentials and technical secrets | anchored prefixes, token shapes, entropy/character-class heuristics, and organization allow/deny rules | no |
|
||||||
|
| hospital- or customer-specific terms/codes | normalized exact dictionary or phrase matching | no |
|
||||||
|
| person/location/organization in otherwise unstructured short text | statistical NER is useful but probabilistic | yes, optional |
|
||||||
|
| clinical meaning in unstructured short Italian text | domain NER or a governed terminology; generic NER is not enough | optional and domain-specific |
|
||||||
|
|
||||||
|
This is not speculative parity with a Python implementation. Presidio's own supported-entity table
|
||||||
|
shows that its global email, IBAN, credit-card and similar recognizers use pattern matching,
|
||||||
|
validation, context and checksums, while its Italian fiscal-code/VAT/passport/identity-card/licence
|
||||||
|
coverage is likewise rule-based
|
||||||
|
([Presidio supported entities](https://presidio.dataprivacystack.org/supported_entities/)). Those
|
||||||
|
mechanisms are portable; Presidio provides tested implementations and orchestration, not a unique
|
||||||
|
Python-language capability.
|
||||||
|
|
||||||
|
For large dictionaries, exact normalized token/phrase matching is still deterministic NLP and does
|
||||||
|
not require a learned model. The policy format should describe the rule rather than the library,
|
||||||
|
for example `kind: email`, `kind: checksum_id`, `kind: regex`, `kind: phrase_set`, and
|
||||||
|
`kind: max_length`. This makes a future engine change possible without changing persisted policy or
|
||||||
|
assessment evidence.
|
||||||
|
|
||||||
|
## What Python/Presidio would materially add
|
||||||
|
|
||||||
|
Presidio is the strongest Python option if ThothII later needs a broader recognizer ecosystem. Its
|
||||||
|
Analyzer composes rule-based recognizers, regexes, validation, NER and contextual enhancement; it
|
||||||
|
also exposes custom recognizers, batch processing and decision tracing
|
||||||
|
([Presidio Analyzer architecture](https://presidio.dataprivacystack.org/analyzer/)). Its current
|
||||||
|
catalog includes Italian identifiers and its YAML registry supports custom patterns and language-
|
||||||
|
specific context. Recent source also contains a `NoOpNlpEngine`, so a deterministic-only Presidio
|
||||||
|
deployment can avoid loading spaCy; this removes model cost but not the Python runtime/process
|
||||||
|
boundary
|
||||||
|
([Presidio `NoOpNlpEngine` integration](https://github.com/data-privacy-stack/presidio/blob/main/presidio-analyzer/presidio_analyzer/recognizer_registry/recognizer_registry.py)).
|
||||||
|
|
||||||
|
`presidio-structured` is conceptually close because it maps tabular columns/JSON keys to detected
|
||||||
|
entities, but its documented strategies select the most common, highest-confidence, or a mixed
|
||||||
|
entity. ThothII would still need its own deadline/sampling/`unknown` semantics and human-override
|
||||||
|
lifecycle; Presidio also lists improved mixed free-text/structured support as future work
|
||||||
|
([Presidio Structured](https://presidio.dataprivacystack.org/structured/)).
|
||||||
|
|
||||||
|
That benefit does not currently justify using it as ThothII's mandatory engine:
|
||||||
|
|
||||||
|
- The backend currently has no PII/NLP dependencies and is an ESM TypeScript service
|
||||||
|
(`backend/package.json`); the existing Python environment belongs to the separate harness/core
|
||||||
|
layer and also has no Presidio or spaCy dependency (`harness/pyproject.toml`). A Python analyzer therefore means a new
|
||||||
|
ownership and deployment boundary, not simply another import.
|
||||||
|
- Presidio is Python-based and can be run as a package or REST container. Its REST API intentionally
|
||||||
|
has no built-in authentication, so a sidecar would have to remain on a protected internal network
|
||||||
|
and be guarded by infrastructure
|
||||||
|
([Presidio FAQ, deployment](https://presidio.dataprivacystack.org/faq/#how-can-i-deploy-presidio-into-my-environment)).
|
||||||
|
- Presidio defaults to English. Adding Italian requires both an Italian NLP pipeline and adapted
|
||||||
|
language-specific recognizers/context; language-agnostic regexes alone do not solve that setup
|
||||||
|
([Presidio multilingual guidance](https://github.com/data-privacy-stack/presidio/blob/main/docs/analyzer/languages.md)).
|
||||||
|
- Presidio itself warns that automated detection cannot guarantee finding all sensitive data and
|
||||||
|
documents an unavoidable false-positive/false-negative trade-off
|
||||||
|
([Presidio FAQ](https://presidio.dataprivacystack.org/faq/#what-is-presidio)). It cannot turn a
|
||||||
|
negative sample into proof that a column is safe.
|
||||||
|
|
||||||
|
## Learned NLP is not an LLM, but it is still a model
|
||||||
|
|
||||||
|
A spaCy NER pipeline does not generate text and is not an LLM. It is a statistical token classifier:
|
||||||
|
it predicts spans such as `PERSON`, `LOCATION` and `ORGANIZATION`. spaCy explicitly notes that the
|
||||||
|
predictions depend on the training examples and will not always be correct
|
||||||
|
([spaCy NER documentation](https://spacy.io/usage/linguistic-features#named-entities)). That makes it
|
||||||
|
a useful additional **positive detector**, not the authority which declares a sampled column safe.
|
||||||
|
|
||||||
|
spaCy publishes Italian CPU pipelines with an NER component, but they are trained on news/media
|
||||||
|
data rather than clinical notes. More importantly, all three official packages (`it_core_news_sm`,
|
||||||
|
`md`, and `lg`) are licensed **CC BY-NC-SA 3.0**, so they are a no-go for a product which can be
|
||||||
|
used commercially; using the MIT-licensed spaCy runtime does not change the weights' license
|
||||||
|
([spaCy Italian pipelines](https://spacy.io/models/it),
|
||||||
|
[`sm` metadata](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_sm-3.8.0.json),
|
||||||
|
[`md` metadata](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_md-3.8.0.json),
|
||||||
|
[`lg` metadata](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_lg-3.8.0.json)). A general Italian model also recognizes
|
||||||
|
names and places, not all sensitive clinical meaning. Training a suitable model would require a
|
||||||
|
labeled, representative and legally usable corpus; Presidio's own model guidance likewise states
|
||||||
|
that a labeled PII dataset is required to train a new model
|
||||||
|
([Presidio spaCy/Stanza guidance](https://github.com/data-privacy-stack/presidio/blob/main/docs/analyzer/nlp_engines/spacy_stanza.md#training-your-own-model)).
|
||||||
|
|
||||||
|
Consequently, the absence of a local LLM is irrelevant to sensitivity assessment. Installations
|
||||||
|
with a local LLM may let description generation receive broader source content under a separate
|
||||||
|
model-boundary policy, but **the sensitivity classifier does not call that LLM**. The optional NER
|
||||||
|
pack is a distinct local classifier capability and is never inferred from the description-model
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
## Licensing and redistribution gate
|
||||||
|
|
||||||
|
Framework code, model weights, and training data are three separate licensing layers. A permissive
|
||||||
|
runtime does not grant ThothII permission to bundle weights trained from restricted data. For an
|
||||||
|
open-source product that may be deployed commercially, the default rule should therefore be:
|
||||||
|
**reject `NC`, research-only, custom restrictive, missing, or ambiguous model licenses**. Pinning an
|
||||||
|
approved artifact must also pin its model card, upstream model, training datasets, required notices,
|
||||||
|
and checksums. This is a technical compatibility screen, not legal advice.
|
||||||
|
|
||||||
|
| Candidate | Framework code | Weights and training-data evidence | Commercial bundling in ThothII | Decision |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Presidio, deterministic recognizers only | MIT; its license permits use and redistribution with the notice ([Presidio license](https://github.com/data-privacy-stack/presidio/blob/main/LICENSE)) | No general Italian model is required with `NoOpNlpEngine`; third-party dependencies still retain their notices | Compatible, but adds a Python process without adding unique detection capability for the agreed baseline | **GO legally; NO-GO architecturally for v1** |
|
||||||
|
| spaCy runtime | MIT ([spaCy license](https://github.com/explosion/spaCy/blob/master/LICENSE)) | Runtime only; no permission is conferred on downloaded pipelines | Compatible if used with separately approved weights | **GO for code only** |
|
||||||
|
| Official spaCy Italian `sm`/`md`/`lg` | spaCy code is MIT | Every current package declares CC BY-NC-SA 3.0; the metadata traces part of training to UD Italian ISDT under the same non-commercial license ([`sm`](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_sm-3.8.0.json), [`md`](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_md-3.8.0.json), [`lg`](https://raw.githubusercontent.com/explosion/spacy-models/master/meta/it_core_news_lg-3.8.0.json)) | `NC` excludes the intended commercial deployments; `SA` adds redistribution obligations | **NO-GO** |
|
||||||
|
| Stanza runtime | Apache-2.0 ([Stanza license](https://github.com/stanfordnlp/stanza/blob/main/LICENSE)) | Stanford says its language packs are ODC-By only to the extent it owns the rights and explicitly tells users to inspect training-data licenses. Italian NER uses FBK's KIND data; KIND annotations are CC BY-NC 4.0 or CC BY-NC-SA 4.0 ([Stanza model licensing note](https://stanfordnlp.github.io/stanza/performance.html), [Italian NER source](https://stanfordnlp.github.io/stanza/ner_models.html), [KIND license](https://github.com/dhfbk/KIND#license)) | The Italian model's non-commercial source data fails the product requirement | **GO for code; NO-GO for Italian weights** |
|
||||||
|
| Flair runtime and official multilingual NER | MIT ([Flair repository and license](https://github.com/flairNLP/flair)) | The official `ner-multi` model card neither includes Italian among its four trained languages nor declares a model license; a maintainer issue asking whether bundled weights are MIT was closed without an answer ([model card](https://huggingface.co/flair/ner-multi), [license issue](https://github.com/flairNLP/flair/issues/1487)) | Runtime is usable, but these weights are unsuitable and their redistribution terms are not established | **GO for code; NO-GO for these weights** |
|
||||||
|
| `osiria/flare-it-ner` | Runs in Transformers/PyTorch; the card labels the weights MIT | Trained on CC BY 4.0 WikiNER plus an additional manually annotated custom dataset whose redistribution terms are not identified in the model card ([model card](https://huggingface.co/osiria/flare-it-ner), [WikiNER dataset](https://figshare.com/articles/dataset/Learning_multilingual_named_entity_recognition_from_Wikipedia/5462500)) | The weights look promising, but an MIT label alone does not resolve the undocumented custom training-data rights | **NO-GO until provenance is explicit** |
|
||||||
|
| Transformers.js / ONNX runtime | Apache-2.0 ([Transformers.js license](https://github.com/huggingface/transformers.js/blob/main/LICENSE)) | ONNX conversion does not replace the source model's license or cure training-data restrictions; each exact model needs its own audit | Compatible runtime, but there is no blanket approval for arbitrary Hub/ONNX weights | **GO for code; weights case by case** |
|
||||||
|
| `Laibniz/italian-ner-pii-browser-uncased` | Packaged for Transformers.js as quantized ONNX; model card says Apache-2.0 | It is a conversion of an Osiria model and therefore inherits the same undocumented custom-data provenance ([model card](https://huggingface.co/Laibniz/italian-ner-pii-browser-uncased)) | CPU/browser packaging does not cure the upstream licensing gap | **NO-GO** |
|
||||||
|
| `Ar86Bat/multilang-pii-ner` | XLM-R/Transformers; model card labels weights MIT | Trained on `ai4privacy/open-pii-masking-500k-ai4privacy`, whose card declares CC BY 4.0 ([model card](https://huggingface.co/Ar86Bat/multilang-pii-ner), [dataset card](https://huggingface.co/datasets/ai4privacy/open-pii-masking-500k-ai4privacy)) | Commercial redistribution appears compatible with MIT notice plus CC BY attribution; an ONNX export, dependency audit, and PSD evaluation are still required | **CONDITIONAL GO as optional PII NER** |
|
||||||
|
| `urchade/gliner_multi_pii-v1` | GLiNER code and model are Apache-2.0 | Its published multilingual synthetic dataset, including Italian, is Apache-2.0 ([model card](https://huggingface.co/urchade/gliner_multi_pii-v1), [dataset card](https://huggingface.co/datasets/urchade/synthetic-pii-ner-mistral-v1)) | The cleanest publicly inspectable license chain, but the published SPY comparison reports substantially lower recall than the selected Fastino model | **GO legally; reserve candidate** |
|
||||||
|
| `fastino/gliner2-privacy-filter-PII-multi` | GLiNER2 code is Apache-2.0; the direct local dependencies are permissively licensed | Weights are Apache-2.0 and the mDeBERTa base is MIT. The authors report a purpose-built synthetic corpus spanning seven languages, including Italian; the corpus itself is not published, so training is not fully reproducible ([model card](https://huggingface.co/fastino/gliner2-privacy-filter-PII-multi), [paper](https://arxiv.org/abs/2605.09973), [base model](https://huggingface.co/microsoft/mdeberta-v3-base)) | Free local/commercial use and redistribution are allowed subject to Apache notices. It has the best reported exact-span F1 and recall among the compared open detectors on SPY, although that evaluation is not Italian-specific | **SELECTED optional NER pack** |
|
||||||
|
| `openai/privacy-filter` | Code and weights are Apache-2.0 and it runs locally through Python or Transformers.js | The publisher explicitly permits commercial deployment, but documents the model as primarily English and warns that non-English performance may drop ([model card](https://huggingface.co/openai/privacy-filter), [source](https://github.com/openai/privacy-filter)) | Clean license and direct TypeScript path, but insufficient Italian evidence and a larger one-billion-parameter artifact make it a weaker fit | **GO legally; NO-GO as primary Italian detector** |
|
||||||
|
| OpenMed Italian PII models | Cards label the weights Apache-2.0 | They are trained on `ai4privacy/pii-masking-400k`, whose license limits use to academic/non-commercial purposes unless a commercial agreement is obtained ([representative model](https://huggingface.co/OpenMed/OpenMed-PII-Italian-BioClinicalBERT-Base-110M-v1), [dataset license](https://huggingface.co/datasets/ai4privacy/pii-masking-400k/blob/main/LICENSE)) | An Apache label on derivative weights does not remove the explicit upstream non-commercial restriction | **NO-GO** |
|
||||||
|
|
||||||
|
The search therefore found a usable, free-of-charge, permissively licensed solution. Fastino
|
||||||
|
GLiNER2-PII is selected because it combines an Apache code/weight grant, explicit Italian training,
|
||||||
|
CPU operation, configurable entity labels, and materially higher published recall than the older
|
||||||
|
Urchade model. This does not prove Italian clinical accuracy: the published SPY evaluation is
|
||||||
|
English and the training corpus is synthetic. Those are quality and reproducibility limitations,
|
||||||
|
not a reason to reintroduce a generative LLM into the classifier.
|
||||||
|
|
||||||
|
## Fit with current ThothII design
|
||||||
|
|
||||||
|
The current backend already owns source sampling and the human-owned `Sensitive Data Flag`; source
|
||||||
|
values are transient and existing description sampling is bounded (`PROJECT_STATE.md` and
|
||||||
|
`backend/src/catalog/description-source-sampler.ts`). The new
|
||||||
|
classifier should therefore be a backend module behind the single effective sensitivity-decision
|
||||||
|
point already being designed, while preserving these agreed semantics:
|
||||||
|
|
||||||
|
1. `sensitive`, `non_sensitive`, and `unknown` are assessment outcomes; `unknown` leaves the
|
||||||
|
human-owned Sensitive Data Flag unchanged. Its effect on later description generation is out of
|
||||||
|
scope here.
|
||||||
|
2. Any positive rule match stops the column scan and yields `sensitive`.
|
||||||
|
3. A negative time-bounded sample yields `unknown`, never `non_sensitive`.
|
||||||
|
4. The administrator may set the final binary flag either way; the automated result remains a
|
||||||
|
proposal with rule/version/coverage evidence and no stored source value.
|
||||||
|
5. Learned NER, if present, can add positive evidence but cannot convert `unknown` to
|
||||||
|
`non_sensitive`.
|
||||||
|
|
||||||
|
The five-second scan budget should be enforced at the database-read orchestration layer and not
|
||||||
|
inside a recognizer library. An in-process deterministic engine maximizes the portion of that budget
|
||||||
|
available to database reads and avoids model cold-start/IPC costs. It should stream bounded batches,
|
||||||
|
check cancellation/deadline between batches, and stop at the first match.
|
||||||
|
|
||||||
|
## Acceptance gate for enabling the selected NER pack
|
||||||
|
|
||||||
|
Build a versioned, value-free fixture corpus representing at least: Italian/general identifiers,
|
||||||
|
false-positive lookalikes, bounded short notes with people/places, credential formats, null/high-
|
||||||
|
cardinality columns, and PSD-specific clinical codes. Measure per category rather than one aggregate
|
||||||
|
score. Pin the GLiNER2 package and model revision/checksum, build an SBOM including transitive
|
||||||
|
dependencies, retain required notices, prohibit model downloads at inference time, and measure warm
|
||||||
|
latency and memory on the target CPU. The detector is enabled only where those checks pass.
|
||||||
|
|
||||||
|
The acceptance test may tune label descriptions and per-label confidence thresholds, but it does
|
||||||
|
not reopen the architecture or silently select another Hub model. If quality or performance is
|
||||||
|
unacceptable, the optional NER pack remains disabled and unresolved sampled columns remain
|
||||||
|
`unknown`. Replacing the chosen model or adding a local-LLM fallback requires a new documented
|
||||||
|
decision.
|
||||||
@@ -278,16 +278,16 @@ export function FleetSelectionActions({ props, direction = "row" }: { props: Var
|
|||||||
{ id: "databases-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete relationships confirmation opened", "changes") },
|
{ id: "databases-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete relationships confirmation opened", "changes") },
|
||||||
];
|
];
|
||||||
if (state.inventoryLevel === "tables") actions = [
|
if (state.inventoryLevel === "tables") actions = [
|
||||||
{ id: "tables-generate", group: "Descriptions", label: "Generate descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) },
|
{ id: "tables-generate", group: "Descriptions", label: "Generate table descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} table${count === 1 ? "" : "s"}`) },
|
||||||
{ id: "tables-sync-columns", group: "Synchronization", label: "Synchronize columns", icon: <RefreshCw size={14} />, disabled, onClick: run("Selected table column synchronization started", "running") },
|
{ id: "tables-sync-columns", group: "Synchronization", label: "Synchronize columns", icon: <RefreshCw size={14} />, disabled, onClick: run("Selected table column synchronization started", "running") },
|
||||||
{ id: "tables-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated descriptions moved to curated Description") },
|
{ id: "tables-consolidate", group: "Descriptions", label: "Copy generated descriptions to Description", icon: <Save size={14} />, disabled, onClick: run("Generated descriptions copied to curated Description") },
|
||||||
{ id: "tables-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
|
{ id: "tables-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
|
||||||
{ id: "tables-delete-columns", group: "Cleanup", label: "Delete all columns", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table columns confirmation opened", "changes") },
|
{ id: "tables-delete-columns", group: "Cleanup", label: "Delete all columns", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table columns confirmation opened", "changes") },
|
||||||
{ id: "tables-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table relationships confirmation opened", "changes") },
|
{ id: "tables-delete-relationships", group: "Cleanup", label: "Delete all relationships", icon: <Trash2 size={14} />, executeLabel: "Review deletion", destructive: true, disabled, onClick: run("Delete table relationships confirmation opened", "changes") },
|
||||||
];
|
];
|
||||||
if (state.inventoryLevel === "columns") actions = [
|
if (state.inventoryLevel === "columns") actions = [
|
||||||
{ id: "columns-generate", group: "Descriptions", label: "Generate descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) },
|
{ id: "columns-generate", group: "Descriptions", label: "Generate column descriptions", icon: <Sparkles size={14} />, disabled, onClick: run(`Description generation started for ${count} column${count === 1 ? "" : "s"}`) },
|
||||||
{ id: "columns-consolidate", group: "Descriptions", label: "Move generated to Description", icon: <Save size={14} />, disabled, onClick: run("Generated column descriptions consolidated") },
|
{ id: "columns-consolidate", group: "Descriptions", label: "Copy generated descriptions to Description", icon: <Save size={14} />, disabled, onClick: run("Generated column descriptions copied") },
|
||||||
{ id: "columns-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
|
{ id: "columns-suggest-sensitive", group: "Sensitive data", label: "Suggest sensitive fields", icon: <ShieldCheck size={14} />, executeLabel: "Open review", disabled, onClick: () => onOpenPanel("sensitive-review") },
|
||||||
{ id: "columns-save-sensitive", group: "Sensitive data", label: `Save sensitive fields (${Object.keys(state.sensitiveDrafts).length})`, icon: <LockKeyhole size={14} />, executeLabel: "Save changes", disabled: Object.keys(state.sensitiveDrafts).length === 0, onClick: onSaveSensitive },
|
{ id: "columns-save-sensitive", group: "Sensitive data", label: `Save sensitive fields (${Object.keys(state.sensitiveDrafts).length})`, icon: <LockKeyhole size={14} />, executeLabel: "Save changes", disabled: Object.keys(state.sensitiveDrafts).length === 0, onClick: onSaveSensitive },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function LoginPage({ config, onAuthenticated, onRetry }: LoginPageProps)
|
|||||||
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
<div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-5xl items-center gap-12 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
||||||
<section className="hidden max-w-xl lg:block">
|
<section className="hidden max-w-xl lg:block">
|
||||||
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
|
<h1 className="max-w-lg font-heading text-5xl font-semibold leading-[1.03] tracking-tight sm:text-6xl">
|
||||||
From intent to SQL, with Human In The Loop
|
From intent to SQL, with a human in the loop
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">AI generates, you guide and approve.</p>
|
<p className="mt-6 max-w-md text-base leading-7 text-muted-foreground">AI generates, you guide and approve.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<>
|
<>
|
||||||
{activeSession?.question && (
|
{activeSession?.question && (
|
||||||
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
<p className="thot-label mb-0.5 text-muted-foreground">Domanda</p>
|
<p className="thot-label mb-0.5 text-muted-foreground">Question</p>
|
||||||
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
|
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
|
||||||
{activeSession.question}
|
{activeSession.question}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -849,7 +849,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
<p className="thot-label mt-1.5">
|
<p className="thot-label mt-1.5">
|
||||||
Datamart Builder with
|
Datamart Builder with
|
||||||
<br />
|
<br />
|
||||||
Human In The Loop
|
Human-in-the-loop review
|
||||||
</p>
|
</p>
|
||||||
{authenticatedUser && (
|
{authenticatedUser && (
|
||||||
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
|
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ test("shows the configured access type and endpoint in catalog status", async ()
|
|||||||
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
|
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
|
||||||
|
|
||||||
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
expect(within(directRow).getByText("Direct PostgreSQL")).toBeVisible();
|
expect(within(directRow).getByText("Direct PostgreSQL connection")).toBeVisible();
|
||||||
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
|
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
|
||||||
|
|
||||||
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
|
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
|
||||||
@@ -502,10 +502,10 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
|||||||
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
|
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
|
||||||
const actions = screen.getByRole("group", { name: "Database management actions" });
|
const actions = screen.getByRole("group", { name: "Database management actions" });
|
||||||
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
|
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
|
||||||
name: "Run descriptions generation history",
|
name: "View description generation history",
|
||||||
});
|
});
|
||||||
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
||||||
name: "Run sensitive suggestions history",
|
name: "View sensitive suggestion history",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
||||||
@@ -513,11 +513,11 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
|||||||
expect(metadataControls).toContainElement(selector);
|
expect(metadataControls).toContainElement(selector);
|
||||||
expect(descriptionHistoryButton).toBeVisible();
|
expect(descriptionHistoryButton).toBeVisible();
|
||||||
expect(descriptionHistoryButton).toBeEnabled();
|
expect(descriptionHistoryButton).toBeEnabled();
|
||||||
expect(descriptionHistoryButton).toHaveTextContent("Run descriptions generation history");
|
expect(descriptionHistoryButton).toHaveTextContent("View description generation history");
|
||||||
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
||||||
expect(suggestionHistoryButton).toBeVisible();
|
expect(suggestionHistoryButton).toBeVisible();
|
||||||
expect(suggestionHistoryButton).toBeEnabled();
|
expect(suggestionHistoryButton).toBeEnabled();
|
||||||
expect(suggestionHistoryButton).toHaveTextContent("Run sensitive suggestions history");
|
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
|
||||||
expect(toolbar.lastElementChild).toBe(actions);
|
expect(toolbar.lastElementChild).toBe(actions);
|
||||||
expect(actions).toHaveClass("sm:justify-end");
|
expect(actions).toHaveClass("sm:justify-end");
|
||||||
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
||||||
@@ -675,7 +675,7 @@ test.each(synchronizationScopes)(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("discloses source sampling before starting Generate Missing", async () => {
|
test("discloses source sampling before starting Generate missing descriptions", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const queuedRun = makeDescriptionGenerationRun({ scope: "missing", total: 3 });
|
const queuedRun = makeDescriptionGenerationRun({ scope: "missing", total: 3 });
|
||||||
let startBody: unknown;
|
let startBody: unknown;
|
||||||
@@ -696,7 +696,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
|||||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||||
|
|
||||||
expect(screen.getByText("Generate missing descriptions for Policlinico San Donato?")).toBeVisible();
|
expect(screen.getByText("Generate missing descriptions for Policlinico San Donato?")).toBeVisible();
|
||||||
expect(screen.getByRole("note", {
|
expect(screen.getByRole("note", {
|
||||||
@@ -706,7 +706,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
|||||||
);
|
);
|
||||||
expect(startBody).toBeUndefined();
|
expect(startBody).toBeUndefined();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||||
await waitFor(() => expect(startBody).toEqual({
|
await waitFor(() => expect(startBody).toEqual({
|
||||||
modelId: "local-qwen",
|
modelId: "local-qwen",
|
||||||
scope: "missing",
|
scope: "missing",
|
||||||
@@ -715,7 +715,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
|||||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps Fleet Generate Missing behind the source-data disclosure", async () => {
|
test("keeps Fleet Generate missing descriptions behind the source-data disclosure", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let generationStarts = 0;
|
let generationStarts = 0;
|
||||||
server.use(
|
server.use(
|
||||||
@@ -753,7 +753,7 @@ test("keeps Fleet Generate Missing behind the source-data disclosure", async ()
|
|||||||
expect(generationStarts).toBe(0);
|
expect(generationStarts).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("confirms Generate All replacement, supports cancel, and sends the database-wide scope", async () => {
|
test("confirms generating all descriptions, supports cancel, and sends the database-wide scope", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const queuedRun = makeDescriptionGenerationRun({ scope: "all", total: 6 });
|
const queuedRun = makeDescriptionGenerationRun({ scope: "all", total: 6 });
|
||||||
let startBody: unknown;
|
let startBody: unknown;
|
||||||
@@ -774,7 +774,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
|
|||||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
||||||
|
|
||||||
expect(screen.getByText("Replace generated descriptions for Policlinico San Donato?")).toBeVisible();
|
expect(screen.getByText("Replace generated descriptions for Policlinico San Donato?")).toBeVisible();
|
||||||
expect(screen.getByText(/existing generated descriptions for eligible tables and columns will be replaced/i)).toBeVisible();
|
expect(screen.getByText(/existing generated descriptions for eligible tables and columns will be replaced/i)).toBeVisible();
|
||||||
@@ -788,8 +788,8 @@ test("confirms Generate All replacement, supports cancel, and sends the database
|
|||||||
expect(startBody).toBeUndefined();
|
expect(startBody).toBeUndefined();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Generate All" }));
|
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
|
||||||
|
|
||||||
await waitFor(() => expect(startBody).toEqual({
|
await waitFor(() => expect(startBody).toEqual({
|
||||||
modelId: "local-qwen",
|
modelId: "local-qwen",
|
||||||
@@ -815,8 +815,8 @@ test("keeps the database selected and safely explains when no descriptions are e
|
|||||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||||
|
|
||||||
expect(await screen.findByText(
|
expect(await screen.findByText(
|
||||||
"No eligible catalog tables or columns need description generation.",
|
"No eligible catalog tables or columns need description generation.",
|
||||||
@@ -876,10 +876,10 @@ test.each([
|
|||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", {
|
await user.click(await screen.findByRole("menuitem", {
|
||||||
name: scope === "all" ? "Generate All" : "Generate Missing",
|
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
||||||
}));
|
}));
|
||||||
await user.click(screen.getByRole("button", {
|
await user.click(screen.getByRole("button", {
|
||||||
name: scope === "all" ? "Generate All" : "Generate Missing",
|
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", {
|
expect(await screen.findByRole("heading", {
|
||||||
@@ -954,9 +954,9 @@ test.each([
|
|||||||
}
|
}
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
|
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -983,16 +983,16 @@ test("disables database-wide generation while a description generation is active
|
|||||||
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||||
|
|
||||||
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
|
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1472,7 +1472,7 @@ test("filters catalog tables as the operator types", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selected table Actions exposes cleanup commands and sends synchronization ids", async () => {
|
test("selected table Actions expose table commands and send synchronization ids", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let startBody: unknown;
|
let startBody: unknown;
|
||||||
const run = makeSyncRun("columns", [patientsTable.id]);
|
const run = makeSyncRun("columns", [patientsTable.id]);
|
||||||
@@ -1494,10 +1494,11 @@ test("selected table Actions exposes cleanup commands and sends synchronization
|
|||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" });
|
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns for selected tables" });
|
||||||
expect(synchronizeColumns).toBeEnabled();
|
expect(synchronizeColumns).toBeEnabled();
|
||||||
expect(screen.getByRole("menuitem", { name: "Delete all columns" })).toBeEnabled();
|
expect(screen.getByRole("menuitem", { name: "Synchronize database tables" })).toBeEnabled();
|
||||||
expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled();
|
expect(screen.getByRole("menuitem", { name: "Remove synchronized columns" })).toBeEnabled();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
|
||||||
await user.click(synchronizeColumns);
|
await user.click(synchronizeColumns);
|
||||||
|
|
||||||
await waitFor(() => expect(startBody).toEqual({
|
await waitFor(() => expect(startBody).toEqual({
|
||||||
@@ -1546,10 +1547,10 @@ test("starts description generation for multiple selected tables", async () => {
|
|||||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
|
||||||
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
|
const generate = await screen.findByRole("menuitem", { name: "Generate table description" });
|
||||||
expect(generate).toBeEnabled();
|
expect(generate).toBeEnabled();
|
||||||
expect(screen.getByRole("menuitem", {
|
expect(screen.getByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
})).toBeEnabled();
|
})).toBeEnabled();
|
||||||
await user.click(generate);
|
await user.click(generate);
|
||||||
|
|
||||||
@@ -1628,9 +1629,9 @@ test("observes an active run from another browser and reopens a terminal run fro
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
const observe = await screen.findByRole("button", {
|
const observe = await screen.findByRole("button", {
|
||||||
name: "Run descriptions generation history",
|
name: "View description generation history",
|
||||||
});
|
});
|
||||||
expect(observe).toHaveTextContent("Run descriptions generation history");
|
expect(observe).toHaveTextContent("View description generation history");
|
||||||
expect(requestedLimit).toBe("50");
|
expect(requestedLimit).toBe("50");
|
||||||
await user.click(observe);
|
await user.click(observe);
|
||||||
|
|
||||||
@@ -1654,9 +1655,9 @@ test("keeps the sensitive suggestion history label stable while showing active s
|
|||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
const historyButton = await screen.findByRole("button", {
|
const historyButton = await screen.findByRole("button", {
|
||||||
name: "Run sensitive suggestions history",
|
name: "View sensitive suggestion history",
|
||||||
});
|
});
|
||||||
expect(historyButton).toHaveTextContent("Run sensitive suggestions history");
|
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
|
||||||
await waitFor(() => expect(historyButton).toHaveAttribute(
|
await waitFor(() => expect(historyButton).toHaveAttribute(
|
||||||
"title",
|
"title",
|
||||||
"Sensitive suggestion generation is active",
|
"Sensitive suggestion generation is active",
|
||||||
@@ -1692,7 +1693,7 @@ test.each([
|
|||||||
client.setQueryData(unrelatedColumnKey, []);
|
client.setQueryData(unrelatedColumnKey, []);
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", {
|
await user.click(await screen.findByRole("button", {
|
||||||
name: "Run descriptions generation history",
|
name: "View description generation history",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
|
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
|
||||||
@@ -1731,7 +1732,7 @@ test("keeps selected tables when description generation cannot start", async ()
|
|||||||
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
||||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
||||||
|
|
||||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||||
expect(screen.getByText("2 selected")).toBeVisible();
|
expect(screen.getByText("2 selected")).toBeVisible();
|
||||||
@@ -1801,7 +1802,7 @@ test("refreshes Catalog Tables and Catalog Columns after table generation comple
|
|||||||
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
||||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||||
await waitFor(() => expect(tableReads).toBe(2));
|
await waitFor(() => expect(tableReads).toBe(2));
|
||||||
@@ -1857,7 +1858,7 @@ test("moves selected generated table descriptions and reports copied and skipped
|
|||||||
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", {
|
await user.click(await screen.findByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await waitFor(() => expect(consolidationBody).toEqual({
|
await waitFor(() => expect(consolidationBody).toEqual({
|
||||||
@@ -1945,8 +1946,9 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
|
|||||||
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
expect(await screen.findByRole("menuitem", { name: "Synchronize columns for this table" })).toBeEnabled();
|
||||||
await user.click(await screen.findByRole("menuitem", {
|
await user.click(await screen.findByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await waitFor(() => expect(consolidationBody).toEqual({
|
await waitFor(() => expect(consolidationBody).toEqual({
|
||||||
@@ -2000,7 +2002,7 @@ test("starts one selected column with the configured default model", async () =>
|
|||||||
expect(columnRow).toBeDefined();
|
expect(columnRow).toBeDefined();
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
|
|
||||||
await waitFor(() => expect(startBody).toEqual({
|
await waitFor(() => expect(startBody).toEqual({
|
||||||
modelId: "local-qwen",
|
modelId: "local-qwen",
|
||||||
@@ -2354,7 +2356,7 @@ test("polls a description run and renders its events in sequence order", async (
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
|
|
||||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||||
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
|
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
|
||||||
@@ -2436,7 +2438,7 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||||
await waitFor(() => expect(columnReads).toBe(2));
|
await waitFor(() => expect(columnReads).toBe(2));
|
||||||
@@ -2469,7 +2471,7 @@ test("keeps the selected column and shows a safe message when generation cannot
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
|
|
||||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||||
expect(screen.getByText("1 selected")).toBeVisible();
|
expect(screen.getByText("1 selected")).toBeVisible();
|
||||||
@@ -2536,7 +2538,7 @@ test("shows a basic failed run without exposing private model or provider fields
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
|
|
||||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||||
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
|
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
|
||||||
@@ -2590,18 +2592,18 @@ test("offers generation and consolidation for multiple selected columns", async
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate description" })).toBeEnabled();
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" })).toBeEnabled();
|
||||||
expect(screen.getByRole("menuitem", {
|
expect(screen.getByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
})).toBeEnabled();
|
})).toBeEnabled();
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
|
const generate = await screen.findByRole("menuitem", { name: "Generate column descriptions" });
|
||||||
expect(generate).toBeEnabled();
|
expect(generate).toBeEnabled();
|
||||||
expect(await screen.findByRole("menuitem", {
|
expect(await screen.findByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
})).toBeEnabled();
|
})).toBeEnabled();
|
||||||
await user.click(generate);
|
await user.click(generate);
|
||||||
|
|
||||||
@@ -2637,7 +2639,7 @@ test.each([
|
|||||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
await user.keyboard("{Escape}");
|
await user.keyboard("{Escape}");
|
||||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||||
@@ -2647,7 +2649,7 @@ test.each([
|
|||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2680,11 +2682,11 @@ test("disables another generation start while the selected-column run is active"
|
|||||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
expect(screen.getByText("1 selected")).toBeVisible();
|
expect(screen.getByText("1 selected")).toBeVisible();
|
||||||
|
|
||||||
@@ -2692,7 +2694,7 @@ test("disables another generation start while the selected-column run is active"
|
|||||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
||||||
.toHaveAttribute("aria-disabled", "true");
|
.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2720,7 +2722,7 @@ test("disables selected description consolidation without database.manage", asyn
|
|||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("menuitem", {
|
expect(await screen.findByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
})).toHaveAttribute("aria-disabled", "true");
|
})).toHaveAttribute("aria-disabled", "true");
|
||||||
expect(consolidationCalls).toBe(0);
|
expect(consolidationCalls).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -2748,7 +2750,7 @@ test("shows an operation conflict without clearing selected descriptions or refr
|
|||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", {
|
await user.click(await screen.findByRole("menuitem", {
|
||||||
name: "Move generated description to Description",
|
name: "Copy generated descriptions to Description",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(await screen.findByText("A database operation is already in progress.")).toBeVisible();
|
expect(await screen.findByText("A database operation is already in progress.")).toBeVisible();
|
||||||
@@ -2756,52 +2758,22 @@ test("shows an operation conflict without clearing selected descriptions or refr
|
|||||||
expect(tableReads).toBe(1);
|
expect(tableReads).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("selected table Actions confirms and deletes incoming and outgoing relationships", async () => {
|
test("selected table Actions do not expose relationship cleanup", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let cleanupBody: unknown;
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||||
http.post("/api/catalog/databases/:databaseId/tables/metadata-cleanup", async ({ request }) => {
|
|
||||||
cleanupBody = await request.json();
|
|
||||||
return HttpResponse.json({ tables: 0, columns: 0, relationships: 2 });
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
const { client } = renderPage({
|
renderPage({
|
||||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||||
});
|
});
|
||||||
const unselectedColumnQuery = [
|
|
||||||
"catalog-columns",
|
|
||||||
patientsTable.databaseId,
|
|
||||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
||||||
] as const;
|
|
||||||
client.setQueryData(unselectedColumnQuery, []);
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
|
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Clear catalog relationships" })).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("Delete all relationships for 1 table?")).toBeVisible();
|
|
||||||
expect(screen.getByText(/incoming and outgoing relationships/i)).toBeVisible();
|
|
||||||
expect(cleanupBody).toBeUndefined();
|
|
||||||
|
|
||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
||||||
expect(screen.queryByText("Delete all relationships for 1 table?")).not.toBeInTheDocument();
|
|
||||||
expect(cleanupBody).toBeUndefined();
|
|
||||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
||||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
||||||
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
|
|
||||||
await user.click(screen.getByRole("button", { name: "Delete relationships" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(cleanupBody).toEqual({
|
|
||||||
tableIds: [patientsTable.id],
|
|
||||||
target: "relationships",
|
|
||||||
}));
|
|
||||||
await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument());
|
|
||||||
expect(client.getQueryState(unselectedColumnQuery)?.isInvalidated).toBe(true);
|
|
||||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Search tables" })).toHaveFocus());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("navigates purely from a database to its tables and edits review metadata", async () => {
|
test("navigates purely from a database to its tables and edits review metadata", async () => {
|
||||||
@@ -2974,9 +2946,10 @@ test("opens the durable job drawer and confirms its exact destructive plan", asy
|
|||||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||||
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
await user.click(await screen.findByRole("menuitem", { name: "Synchronize tables" }));
|
||||||
|
|
||||||
const synchronizationDrawer = await screen.findByRole("dialog", {
|
const synchronizationDrawer = await screen.findByRole("dialog", {
|
||||||
name: "Schema synchronization",
|
name: "Schema synchronization",
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ export function DatabaseManagementPage({
|
|||||||
scope,
|
scope,
|
||||||
);
|
);
|
||||||
rememberDescriptionGenerationRun(run);
|
rememberDescriptionGenerationRun(run);
|
||||||
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
|
toast.success(`${scope === "all" ? "Generate all descriptions" : "Generate missing descriptions"} started for ${database.workspaceName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(apiErrorMessage(error));
|
toast.error(apiErrorMessage(error));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1274,13 +1274,13 @@ export function DatabaseManagementPage({
|
|||||||
onSelectedModelChange={setSelectedMetadataModel}
|
onSelectedModelChange={setSelectedMetadataModel}
|
||||||
/>
|
/>
|
||||||
{screen.kind === "list" ? <>
|
{screen.kind === "list" ? <>
|
||||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run descriptions generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
|
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
|
||||||
<History /> Run descriptions generation history
|
<History /> View description generation history
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run sensitive suggestions history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
|
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitive suggestion history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
|
||||||
<History />
|
<History />
|
||||||
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
|
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
|
||||||
Run sensitive suggestions history
|
View sensitive suggestion history
|
||||||
</Button>
|
</Button>
|
||||||
</> : null}
|
</> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -356,7 +356,7 @@ test("shows an explicit recoverable incomplete state when no provider model is a
|
|||||||
renderManagement();
|
renderManagement();
|
||||||
|
|
||||||
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
|
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
|
||||||
expect(incomplete).toHaveTextContent("No enabled provider and model choices are available");
|
expect(incomplete).toHaveTextContent("No provider or model options are available");
|
||||||
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
|
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
|
||||||
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
|
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
|||||||
state={credentials === "present" ? "ready" : "attention"}
|
state={credentials === "present" ? "ready" : "attention"}
|
||||||
/>
|
/>
|
||||||
<RailItem
|
<RailItem
|
||||||
label="Saved-config test"
|
label="Configuration test"
|
||||||
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
|
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
|
||||||
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
|
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
|
||||||
/>
|
/>
|
||||||
@@ -505,7 +505,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
) : (
|
) : (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||||
<p className="font-semibold">No enabled provider and model choices are available.</p>
|
<p className="font-semibold">No provider or model options are available.</p>
|
||||||
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ export function ComposerFooter() {
|
|||||||
</FooterSelect>
|
</FooterSelect>
|
||||||
<span
|
<span
|
||||||
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
||||||
aria-label="Input non-cached, cached, and output tokens"
|
aria-label="Input, cached, and output tokens"
|
||||||
>
|
>
|
||||||
{formatTokenUsage(tokenUsage)}
|
{formatTokenUsage(tokenUsage)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import { ApiError, apiErrorMessage } from "../../api/client";
|
|||||||
import {
|
import {
|
||||||
consolidateCatalogDescriptions,
|
consolidateCatalogDescriptions,
|
||||||
listCatalogColumns,
|
listCatalogColumns,
|
||||||
|
startCatalogSync,
|
||||||
startDescriptionGenerationRun,
|
startDescriptionGenerationRun,
|
||||||
updateCatalogColumnMetadata,
|
updateCatalogColumnMetadata,
|
||||||
type CatalogColumn,
|
type CatalogColumn,
|
||||||
|
type CatalogSyncRun,
|
||||||
type CatalogTable,
|
type CatalogTable,
|
||||||
type DescriptionGenerationRun,
|
type DescriptionGenerationRun,
|
||||||
type SensitiveDataSuggestionRequest,
|
type SensitiveDataSuggestionRequest,
|
||||||
@@ -25,10 +27,12 @@ import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationMo
|
|||||||
interface Props {
|
interface Props {
|
||||||
databaseId: string;
|
databaseId: string;
|
||||||
table: CatalogTable;
|
table: CatalogTable;
|
||||||
|
databaseVersion: number;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
selectedMetadataModel: string | null;
|
selectedMetadataModel: string | null;
|
||||||
descriptionGenerationActive: boolean;
|
descriptionGenerationActive: boolean;
|
||||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||||
|
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
|
||||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||||
catalogOperationActive?: boolean;
|
catalogOperationActive?: boolean;
|
||||||
@@ -88,10 +92,12 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
|
|||||||
export function DatabaseColumns({
|
export function DatabaseColumns({
|
||||||
databaseId,
|
databaseId,
|
||||||
table,
|
table,
|
||||||
|
databaseVersion,
|
||||||
canManage,
|
canManage,
|
||||||
selectedMetadataModel,
|
selectedMetadataModel,
|
||||||
descriptionGenerationActive,
|
descriptionGenerationActive,
|
||||||
onDescriptionGenerationRunStarted,
|
onDescriptionGenerationRunStarted,
|
||||||
|
onCatalogSyncRunStarted,
|
||||||
onNavigationStateChange,
|
onNavigationStateChange,
|
||||||
onSuggestSensitive,
|
onSuggestSensitive,
|
||||||
catalogOperationActive = false,
|
catalogOperationActive = false,
|
||||||
@@ -285,15 +291,33 @@ export function DatabaseColumns({
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FleetColumnAction =
|
type FleetColumnAction =
|
||||||
|
| "sync-columns"
|
||||||
| "generate-descriptions"
|
| "generate-descriptions"
|
||||||
| "consolidate-descriptions"
|
| "consolidate-descriptions"
|
||||||
| "suggest-sensitive"
|
| "suggest-sensitive"
|
||||||
| "save-sensitive";
|
| "save-sensitive";
|
||||||
|
|
||||||
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
||||||
|
{
|
||||||
|
id: "sync-columns",
|
||||||
|
label: "Synchronize columns for this table",
|
||||||
|
group: "Synchronization",
|
||||||
|
scopeLabel: "Selected table",
|
||||||
|
runLabel: "Synchronize",
|
||||||
|
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
||||||
|
disabledReason: !canManage
|
||||||
|
? "You do not have permission to synchronize the catalog."
|
||||||
|
: selectedIds.length === 0
|
||||||
|
? "Select at least one column."
|
||||||
|
: catalogOperationActive
|
||||||
|
? "Wait for the active catalog operation to finish."
|
||||||
|
: busy
|
||||||
|
? "Another action is running."
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "generate-descriptions",
|
id: "generate-descriptions",
|
||||||
label: "Generate descriptions",
|
label: "Generate column descriptions",
|
||||||
group: "Descriptions",
|
group: "Descriptions",
|
||||||
runLabel: "Generate",
|
runLabel: "Generate",
|
||||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
||||||
@@ -311,7 +335,7 @@ export function DatabaseColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "consolidate-descriptions",
|
id: "consolidate-descriptions",
|
||||||
label: "Move generated to Description",
|
label: "Copy generated descriptions to Description",
|
||||||
group: "Descriptions",
|
group: "Descriptions",
|
||||||
runLabel: "Move",
|
runLabel: "Move",
|
||||||
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
||||||
@@ -345,14 +369,14 @@ export function DatabaseColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "save-sensitive",
|
id: "save-sensitive",
|
||||||
label: "Save sensitive-field changes",
|
label: "Save sensitive field changes",
|
||||||
group: "Sensitive data",
|
group: "Sensitive data",
|
||||||
runLabel: "Save",
|
runLabel: "Save",
|
||||||
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
|
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
|
||||||
disabledReason: !canManage
|
disabledReason: !canManage
|
||||||
? "You do not have permission to update sensitive fields."
|
? "You do not have permission to update sensitive fields."
|
||||||
: changedSensitiveColumns.length === 0
|
: changedSensitiveColumns.length === 0
|
||||||
? "No sensitive-field changes are waiting to be saved."
|
? "No sensitive field changes to save."
|
||||||
: catalogOperationActive
|
: catalogOperationActive
|
||||||
? "Wait for the active catalog operation to finish."
|
? "Wait for the active catalog operation to finish."
|
||||||
: busy
|
: busy
|
||||||
@@ -362,7 +386,8 @@ export function DatabaseColumns({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const runFleetAction = async (action: FleetColumnAction) => {
|
const runFleetAction = async (action: FleetColumnAction) => {
|
||||||
if (action === "generate-descriptions") await generateDescriptions();
|
if (action === "sync-columns") await synchronizeColumns();
|
||||||
|
else if (action === "generate-descriptions") await generateDescriptions();
|
||||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||||
else await saveSensitive();
|
else await saveSensitive();
|
||||||
@@ -373,6 +398,22 @@ export function DatabaseColumns({
|
|||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const synchronizeColumns = async () => {
|
||||||
|
if (selectedIds.length === 0) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const run = await startCatalogSync(databaseId, databaseVersion, "columns", [table.id]);
|
||||||
|
onCatalogSyncRunStarted?.(run);
|
||||||
|
gridRef.current?.api.deselectAll();
|
||||||
|
setSelectedIds([]);
|
||||||
|
toast.success("Column synchronization started");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(apiErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
||||||
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
||||||
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||||
@@ -398,7 +439,7 @@ export function DatabaseColumns({
|
|||||||
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
|
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
|
||||||
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
||||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
||||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
|
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,8 +497,9 @@ export function DatabaseColumns({
|
|||||||
<Menu.Portal>
|
<Menu.Portal>
|
||||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</Menu.Item>
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
|
||||||
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
|
||||||
</Menu.Popup>
|
</Menu.Popup>
|
||||||
</Menu.Positioner>
|
</Menu.Positioner>
|
||||||
</Menu.Portal>
|
</Menu.Portal>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
|
|||||||
presentation="fleet"
|
presentation="fleet"
|
||||||
databaseId={database.id!}
|
databaseId={database.id!}
|
||||||
table={table}
|
table={table}
|
||||||
|
databaseVersion={database.version}
|
||||||
canManage
|
canManage
|
||||||
selectedMetadataModel={null}
|
selectedMetadataModel={null}
|
||||||
descriptionGenerationActive={false}
|
descriptionGenerationActive={false}
|
||||||
@@ -124,7 +125,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
|
|||||||
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
||||||
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Retry columns" }));
|
await user.click(screen.getByRole("button", { name: "Retry loading columns" }));
|
||||||
await waitFor(() => expect(requests).toBe(2));
|
await waitFor(() => expect(requests).toBe(2));
|
||||||
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ export function DatabaseForm({
|
|||||||
disabled={readOnly || busy}
|
disabled={readOnly || busy}
|
||||||
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
|
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
|
||||||
>
|
>
|
||||||
<option value="postgres_direct">Direct PostgreSQL</option>
|
<option value="postgres_direct">Direct PostgreSQL connection</option>
|
||||||
<option value="rest_api">REST API</option>
|
<option value="rest_api">REST API</option>
|
||||||
<option value="ssh_tunnel">SSH tunnel</option>
|
<option value="ssh_tunnel">SSH tunnel</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -149,13 +149,13 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
|
|||||||
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
|
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: "Direct PostgreSQL",
|
label: "Direct PostgreSQL connection",
|
||||||
detail: endpoint(row.binding.host, row.binding.port),
|
detail: endpoint(row.binding.host, row.binding.port),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
|
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
|
||||||
if (total === 0) return { label: "Not started", detail: "No catalog items", tone: "danger" };
|
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
|
||||||
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
|
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
|
||||||
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
|
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
|
||||||
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
|
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
|
||||||
@@ -172,7 +172,7 @@ function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDataba
|
|||||||
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
|
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
|
||||||
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
|
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
|
||||||
const sensitive = !sensitiveRun
|
const sensitive = !sensitiveRun
|
||||||
? { label: "Not started", detail: "No analysis run", tone: "danger" as const }
|
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
|
||||||
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
|
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
|
||||||
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
|
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
|
||||||
: ["failed", "interrupted"].includes(sensitiveRun.status)
|
: ["failed", "interrupted"].includes(sensitiveRun.status)
|
||||||
@@ -608,7 +608,7 @@ export function DatabaseGrid({
|
|||||||
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
|
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pendingGenerationScope === "all" ? "Generate All" : "Generate Missing"}
|
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : pendingDelete ? (
|
) : pendingDelete ? (
|
||||||
@@ -688,14 +688,14 @@ export function DatabaseGrid({
|
|||||||
disabled={!canGenerateDescriptions}
|
disabled={!canGenerateDescriptions}
|
||||||
onClick={() => setPendingGenerationScope("all")}
|
onClick={() => setPendingGenerationScope("all")}
|
||||||
>
|
>
|
||||||
Generate All
|
Generate all descriptions
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||||
disabled={!canGenerateDescriptions}
|
disabled={!canGenerateDescriptions}
|
||||||
onClick={() => setPendingGenerationScope("missing")}
|
onClick={() => setPendingGenerationScope("missing")}
|
||||||
>
|
>
|
||||||
Generate Missing
|
Generate missing descriptions
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Separator className="my-1 h-px bg-border" />
|
<Menu.Separator className="my-1 h-px bg-border" />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export function DatabaseRelationships({
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Synchronization">
|
<optgroup label="Synchronization">
|
||||||
<option value="relationships">Synchronize physical relationships</option>
|
<option value="relationships">Synchronize physical relationships</option>
|
||||||
<option value="all">Synchronize the full schema for this database</option>
|
<option value="all">Synchronize the full database schema</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
|
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
|
||||||
@@ -414,7 +414,7 @@ export function DatabaseRelationships({
|
|||||||
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
|
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
|
||||||
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
|
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>}
|
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
|
||||||
</div>
|
</div>
|
||||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
|
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
|
||||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||||
@@ -430,7 +430,7 @@ export function DatabaseRelationships({
|
|||||||
title="Add relationship"
|
title="Add relationship"
|
||||||
description="Connect one source column to one primary-key target column."
|
description="Connect one source column to one primary-key target column."
|
||||||
onClose={closeAdd}
|
onClose={closeAdd}
|
||||||
closeLabel="Close add relationship"
|
closeLabel="Close relationship form"
|
||||||
busy={busy}
|
busy={busy}
|
||||||
footer={(
|
footer={(
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -330,13 +330,12 @@ export function DatabaseTables({
|
|||||||
| "sync-columns"
|
| "sync-columns"
|
||||||
| "consolidate-descriptions"
|
| "consolidate-descriptions"
|
||||||
| "suggest-sensitive"
|
| "suggest-sensitive"
|
||||||
| "clear-columns"
|
| "clear-columns";
|
||||||
| "clear-relationships";
|
|
||||||
|
|
||||||
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
|
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
|
||||||
{
|
{
|
||||||
id: "generate-descriptions",
|
id: "generate-descriptions",
|
||||||
label: "Generate descriptions",
|
label: "Generate table description",
|
||||||
group: "Descriptions",
|
group: "Descriptions",
|
||||||
runLabel: "Generate",
|
runLabel: "Generate",
|
||||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
||||||
@@ -372,7 +371,7 @@ export function DatabaseTables({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "consolidate-descriptions",
|
id: "consolidate-descriptions",
|
||||||
label: "Move generated to Description",
|
label: "Copy generated descriptions to Description",
|
||||||
group: "Descriptions",
|
group: "Descriptions",
|
||||||
runLabel: "Move",
|
runLabel: "Move",
|
||||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||||
@@ -386,13 +385,15 @@ export function DatabaseTables({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sync-tables",
|
id: "sync-tables",
|
||||||
label: "Synchronize tables",
|
label: "Synchronize database tables",
|
||||||
group: "Synchronization",
|
group: "Synchronization",
|
||||||
scopeLabel: "All tables in this database",
|
scopeLabel: "Selected database",
|
||||||
runLabel: "Synchronize",
|
runLabel: "Synchronize",
|
||||||
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
|
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
||||||
disabledReason: !canManage
|
disabledReason: !canManage
|
||||||
? "You do not have permission to synchronize the catalog."
|
? "You do not have permission to synchronize the catalog."
|
||||||
|
: selectedIds.length === 0
|
||||||
|
? "Select at least one table."
|
||||||
: !bindingReady
|
: !bindingReady
|
||||||
? "Test the current database binding first."
|
? "Test the current database binding first."
|
||||||
: busy !== null || Boolean(currentRun)
|
: busy !== null || Boolean(currentRun)
|
||||||
@@ -401,7 +402,7 @@ export function DatabaseTables({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sync-columns",
|
id: "sync-columns",
|
||||||
label: "Synchronize columns",
|
label: "Synchronize columns for selected tables",
|
||||||
group: "Synchronization",
|
group: "Synchronization",
|
||||||
runLabel: "Synchronize",
|
runLabel: "Synchronize",
|
||||||
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
||||||
@@ -435,22 +436,7 @@ export function DatabaseTables({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "clear-columns",
|
id: "clear-columns",
|
||||||
label: "Clear catalog columns",
|
label: "Remove synchronized columns",
|
||||||
group: "Catalog cleanup",
|
|
||||||
tone: "destructive",
|
|
||||||
runLabel: "Review cleanup",
|
|
||||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
|
||||||
disabledReason: !canManage
|
|
||||||
? "You do not have permission to clear catalog metadata."
|
|
||||||
: selectedIds.length === 0
|
|
||||||
? "Select at least one table."
|
|
||||||
: busy !== null || Boolean(currentRun)
|
|
||||||
? "Wait for the active catalog operation to finish."
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "clear-relationships",
|
|
||||||
label: "Clear catalog relationships",
|
|
||||||
group: "Catalog cleanup",
|
group: "Catalog cleanup",
|
||||||
tone: "destructive",
|
tone: "destructive",
|
||||||
runLabel: "Review cleanup",
|
runLabel: "Review cleanup",
|
||||||
@@ -473,7 +459,6 @@ export function DatabaseTables({
|
|||||||
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
||||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||||
else if (action === "clear-columns") setPendingDelete("columns");
|
else if (action === "clear-columns") setPendingDelete("columns");
|
||||||
else setPendingDelete("relationships");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelection = () => {
|
const clearSelection = () => {
|
||||||
@@ -544,10 +529,12 @@ export function DatabaseTables({
|
|||||||
<DatabaseColumns
|
<DatabaseColumns
|
||||||
databaseId={databaseId}
|
databaseId={databaseId}
|
||||||
table={activeTable}
|
table={activeTable}
|
||||||
|
databaseVersion={database.version}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
selectedMetadataModel={selectedMetadataModel}
|
selectedMetadataModel={selectedMetadataModel}
|
||||||
descriptionGenerationActive={descriptionGenerationActive}
|
descriptionGenerationActive={descriptionGenerationActive}
|
||||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||||
|
onCatalogSyncRunStarted={onRunStarted}
|
||||||
onNavigationStateChange={setColumnNavigation}
|
onNavigationStateChange={setColumnNavigation}
|
||||||
onSuggestSensitive={onSuggestSensitive}
|
onSuggestSensitive={onSuggestSensitive}
|
||||||
catalogOperationActive={Boolean(currentRun)}
|
catalogOperationActive={Boolean(currentRun)}
|
||||||
@@ -594,7 +581,7 @@ export function DatabaseTables({
|
|||||||
rowHeight={44}
|
rowHeight={44}
|
||||||
headerHeight={38}
|
headerHeight={38}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
@@ -655,10 +642,12 @@ export function DatabaseTables({
|
|||||||
<DatabaseColumns
|
<DatabaseColumns
|
||||||
databaseId={databaseId}
|
databaseId={databaseId}
|
||||||
table={activeTable}
|
table={activeTable}
|
||||||
|
databaseVersion={database.version}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
selectedMetadataModel={selectedMetadataModel}
|
selectedMetadataModel={selectedMetadataModel}
|
||||||
descriptionGenerationActive={descriptionGenerationActive}
|
descriptionGenerationActive={descriptionGenerationActive}
|
||||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||||
|
onCatalogSyncRunStarted={onRunStarted}
|
||||||
onNavigationStateChange={setColumnNavigation}
|
onNavigationStateChange={setColumnNavigation}
|
||||||
onSuggestSensitive={onSuggestSensitive}
|
onSuggestSensitive={onSuggestSensitive}
|
||||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||||
@@ -710,19 +699,11 @@ export function DatabaseTables({
|
|||||||
<div className="mr-auto min-w-56">
|
<div className="mr-auto min-w-56">
|
||||||
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
||||||
{presentation === "fleet"
|
{presentation === "fleet"
|
||||||
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||||
: pendingDelete === "columns"
|
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
|
||||||
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
|
||||||
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{presentation === "fleet"
|
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
|
||||||
? pendingDelete === "columns"
|
|
||||||
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
|
|
||||||
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
|
|
||||||
: pendingDelete === "columns"
|
|
||||||
? "Only columns belonging to the selected catalog tables will be removed."
|
|
||||||
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
|
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
|
||||||
@@ -733,7 +714,7 @@ export function DatabaseTables({
|
|||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onClick={() => void deleteMetadata(pendingDelete)}
|
onClick={() => void deleteMetadata(pendingDelete)}
|
||||||
>
|
>
|
||||||
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
|
<Trash2 />Remove synchronized columns
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -760,13 +741,13 @@ export function DatabaseTables({
|
|||||||
<Menu.Portal>
|
<Menu.Portal>
|
||||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate table description</Menu.Item>
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</Menu.Item>
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns for selected tables</Menu.Item>
|
||||||
|
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
|
||||||
<Menu.Separator className="my-1 h-px bg-border" />
|
<Menu.Separator className="my-1 h-px bg-border" />
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
|
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Remove synchronized columns</Menu.Item>
|
||||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
|
|
||||||
</Menu.Popup>
|
</Menu.Popup>
|
||||||
</Menu.Positioner>
|
</Menu.Positioner>
|
||||||
</Menu.Portal>
|
</Menu.Portal>
|
||||||
@@ -799,12 +780,11 @@ export function DatabaseTables({
|
|||||||
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||||
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
|
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
|
||||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||||
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
|
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing columns.</div> : null}
|
||||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||||
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
|
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
|
||||||
<AgGridReact<CatalogTable>
|
<AgGridReact<CatalogTable>
|
||||||
@@ -825,7 +805,7 @@ export function DatabaseTables({
|
|||||||
rowHeight={44}
|
rowHeight={44}
|
||||||
headerHeight={38}
|
headerHeight={38}
|
||||||
animateRows={false}
|
animateRows={false}
|
||||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const actions = [
|
|||||||
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
|
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
|
||||||
{
|
{
|
||||||
id: "generate",
|
id: "generate",
|
||||||
label: "Generate descriptions",
|
label: "Generate column descriptions",
|
||||||
group: "Descriptions",
|
group: "Descriptions",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
disabledReason: "Choose a metadata model first",
|
disabledReason: "Choose a metadata model first",
|
||||||
|
|||||||
@@ -27,4 +27,10 @@ test("uses operational body text for every available-command list", () => {
|
|||||||
expect(styles).toMatch(
|
expect(styles).toMatch(
|
||||||
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
|
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
|
||||||
);
|
);
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
|
||||||
|
);
|
||||||
|
expect(styles).toMatch(
|
||||||
|
/\.thot-fleet-action-selector__select\s*\{[^}]*width:\s*max-content;/s,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -381,7 +381,8 @@
|
|||||||
.thot-fleet-action-selector {
|
.thot-fleet-action-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
max-width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -402,9 +403,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thot-fleet-action-selector__select {
|
.thot-fleet-action-selector__select {
|
||||||
flex: 1 1 13rem;
|
flex: 0 0 auto;
|
||||||
width: min(100%, 22rem);
|
width: max-content;
|
||||||
min-width: 13rem;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
padding: 0 2rem 0 0.625rem;
|
padding: 0 2rem 0 0.625rem;
|
||||||
border: 1px solid oklch(var(--input));
|
border: 1px solid oklch(var(--input));
|
||||||
@@ -613,8 +615,8 @@
|
|||||||
|
|
||||||
@media (min-width: 48rem) {
|
@media (min-width: 48rem) {
|
||||||
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
|
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
|
||||||
flex: 1 1 33rem;
|
flex: 0 0 auto;
|
||||||
min-width: 33rem;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -963,9 +965,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thot-fleet-action-selector__select {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-height: 44rem) and (min-width: 48rem) {
|
@media (max-height: 44rem) and (min-width: 48rem) {
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ describe("history action placement", () => {
|
|||||||
expect(fleetHeader).not.toContain("Sensitive history");
|
expect(fleetHeader).not.toContain("Sensitive history");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("database views expose metadata histories beside database commands", () => {
|
test("database tables do not own metadata history actions", () => {
|
||||||
for (const file of ["DatabaseForm.tsx", "DatabaseTables.tsx", "DatabaseRelationships.tsx"]) {
|
for (const file of ["DatabaseTables.tsx"]) {
|
||||||
|
const source = componentSource(file);
|
||||||
|
expect(source, file).not.toContain("Description history");
|
||||||
|
expect(source, file).not.toContain("Sensitive history");
|
||||||
|
}
|
||||||
|
for (const file of ["DatabaseForm.tsx", "DatabaseRelationships.tsx"]) {
|
||||||
const source = componentSource(file);
|
const source = componentSource(file);
|
||||||
expect(source, file).toContain("Description history");
|
expect(source, file).toContain("Description history");
|
||||||
expect(source, file).toContain("Sensitive history");
|
expect(source, file).toContain("Sensitive history");
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ test("compacts table, filter, detail, and rationale rows", () => {
|
|||||||
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
|
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
|
test("shows 'No dependencies' for a CTE with an empty depends_on", () => {
|
||||||
render(<CtePlanViewer plan={plan} />);
|
render(<CtePlanViewer plan={plan} />);
|
||||||
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">no dependencies</p>
|
<p className="text-xs text-muted-foreground">No dependencies</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ test("(b) clicking toggle switches to table listing a promoted table name", asyn
|
|||||||
test("(c) oversized linking (>45) shows cap notice", () => {
|
test("(c) oversized linking (>45) shows cap notice", () => {
|
||||||
render(<SchemaLinkingViewer linking={bigLinking} />);
|
render(<SchemaLinkingViewer linking={bigLinking} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(/too many elements for the graph/i)
|
screen.getByText(/too many nodes to display in the graph/i)
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
|||||||
|
|
||||||
{oversized && (
|
{oversized && (
|
||||||
<p className="text-sm text-amber-600">
|
<p className="text-sm text-amber-600">
|
||||||
Too many elements for the graph, use the table
|
Too many nodes to display in the graph. Use the table instead.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
|
|||||||
{descriptor.artifact ? (
|
{descriptor.artifact ? (
|
||||||
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
|
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">No artifact.</p>
|
<p className="text-sm text-muted-foreground">No artifact available.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
|||||||
{o.rationale && (
|
{o.rationale && (
|
||||||
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
|
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
|
||||||
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Motivo
|
Reason
|
||||||
</div>
|
</div>
|
||||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
|
||||||
@@ -78,7 +78,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
|||||||
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
|
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
|
||||||
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
|
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
|
||||||
<div className="mb-1 font-semibold uppercase tracking-wide">
|
<div className="mb-1 font-semibold uppercase tracking-wide">
|
||||||
Domanda di contesto
|
Context question
|
||||||
</div>
|
</div>
|
||||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SelectWidget } from "./SelectWidget";
|
|||||||
test("picking an option responds with its id", async () => {
|
test("picking an option responds with its id", async () => {
|
||||||
const onRespond = vi.fn();
|
const onRespond = vi.fn();
|
||||||
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }, { id: "b", label: "B", recommended: true }] }} onRespond={onRespond} />);
|
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }, { id: "b", label: "B", recommended: true }] }} onRespond={onRespond} />);
|
||||||
expect(screen.getByText(/consigliato/i)).toBeInTheDocument();
|
expect(screen.getByText(/recommended/i)).toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: /A/ }));
|
await userEvent.click(screen.getByRole("button", { name: /A/ }));
|
||||||
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] });
|
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "select", choices: ["a"] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
|
|||||||
<span>{o.label}</span>
|
<span>{o.label}</span>
|
||||||
{o.recommended && (
|
{o.recommended && (
|
||||||
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
|
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
|
||||||
consigliato
|
Recommended
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user