From c8dd5aad4e66e0181a7ff4192fc338be94467231 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sat, 16 May 2026 13:20:43 +0300 Subject: [PATCH] feat(schema): Phase 1 schema templates + Nexus DevOps runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## AI Schema Assist Phase 1 (steps 1-2) Approach A core (templates only, no LLM) — admin начинает создание нового справочника с curated skeleton вместо пустого Monaco. Approach C (per-field LLM suggest) — Phase 2 на этом фундаменте. ### Backend - `templates/*.template.json` в ordinis-cuod-bundle resources — 6 шаблонов (blank/spacecraft/ground-station/antenna/frequency-band/operator) - `SchemaTemplateRegistry` (rest-api) — classpath scan at startup, strip x-template-* metadata, expose canonical JSON Schema - `SchemaTemplatesController` — GET /api/v1/dictionaries/templates (list) + /{id} (detail с schemaJson) - Cross-bundle support: scanner matches classpath*:templates/*.template.json — другие bundles могут shippит свои templates через те же conventions ### Frontend - types: SchemaTemplateSummary + SchemaTemplateDetail - queries: schemaTemplatesQuery + lazy detail query - TemplatePicker.tsx — grid of cards (icon + name + description + tags), click → fetch detail → fire onPick(detail) - Integrated в DictionaryEditorDialog (create mode, properties.length===0 visible — после первой property picker исчезает чтобы не перезатереть) ## Nexus DevOps handoff `docs-internal/ops/nexus-alpine-mirror.md` — runbook про APKINDEX vs file inventory drift на v3.23 mirror. Includes: - Verification commands - 3 fix options (invalidate cache / scheduled refresh / self-host) - Prometheus alert proposal - Current workaround (node:22-alpine3.20 pin) reference Active issue paired с CI saga !227/!229/!230 — нужен DevOps action. --- docs-internal/ops/nexus-alpine-mirror.md | 123 +++++++++++++++ ordinis-admin-ui/src/api/client.ts | 18 +++ ordinis-admin-ui/src/api/queries.ts | 36 +++++ .../schema/DictionaryEditorDialog.tsx | 18 +++ .../src/components/schema/TemplatePicker.tsx | 136 +++++++++++++++++ .../resources/templates/antenna.template.json | 41 +++++ .../resources/templates/blank.template.json | 21 +++ .../templates/frequency-band.template.json | 40 +++++ .../templates/ground-station.template.json | 47 ++++++ .../templates/operator.template.json | 40 +++++ .../templates/spacecraft.template.json | 41 +++++ .../template/SchemaTemplateRegistry.java | 143 ++++++++++++++++++ .../web/SchemaTemplatesController.java | 69 +++++++++ 13 files changed, 773 insertions(+) create mode 100644 docs-internal/ops/nexus-alpine-mirror.md create mode 100644 ordinis-admin-ui/src/components/schema/TemplatePicker.tsx create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/antenna.template.json create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/blank.template.json create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/frequency-band.template.json create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/ground-station.template.json create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/operator.template.json create mode 100644 ordinis-cuod-bundle/src/main/resources/templates/spacecraft.template.json create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/template/SchemaTemplateRegistry.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaTemplatesController.java diff --git a/docs-internal/ops/nexus-alpine-mirror.md b/docs-internal/ops/nexus-alpine-mirror.md new file mode 100644 index 0000000..87e454c --- /dev/null +++ b/docs-internal/ops/nexus-alpine-mirror.md @@ -0,0 +1,123 @@ +# Nexus Alpine Mirror — Operations Runbook + +**Owner:** DevOps (cluster.265 infra) +**Status:** Active issue — APKINDEX рассинхронизирован с file storage на v3.23 +**Related:** ordinis ci/apk fixes chain (!227, !229, !230) + +--- + +## Problem + +Nexus mirror на `repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/`: + +| Что | Состояние | +|---|---| +| APKINDEX.tar.gz | Claims `ca-certificates-20251003-r0`, `libexpat-2.7.3-r0` | +| Filesystem | Только `ca-certificates-20260413-r0.apk`, `libexpat-2.7.5-r0.apk` | +| Result | `apk add` → HTTP 404 на 2 packages → fail | + +Проверить актуальность: +```bash +curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz \ + | tar xz -C /tmp/ \ + && grep -A1 "^P:ca-certificates$" /tmp/APKINDEX | head -2 + +# Сравнить с file inventory: +curl -s "https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/" \ + | grep "ca-certificates-" | head -3 +``` + +v3.20 mirror — **consistent** (APKINDEX и files matching). v3.21+ — нужно проверить per-version. + +--- + +## Impact + +- `node:22-alpine` (latest) uses Alpine v3.23 → broken `apk add` в CI +- Releases pipeline ломается на 2 stages (release job → semantic-release) +- `propose-prod-image-bump` (alpine:3.20 image) — **работает** благодаря consistent v3.20 mirror + +## Workaround (active в проде) + +ordinis `.gitlab-ci.yml` pins `release` job: +```yaml +release: + image: repo.nstart.cloud/library/node:22-alpine3.20 # explicit v3.20 pin +``` + +Все Alpine jobs делают sed swap к internal mirror + `apk update`: +```yaml +- sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://repo.nstart.cloud/repository/alpine|g' /etc/apk/repositories +- apk update +- apk add --no-cache +``` + +--- + +## Fix (нужен DevOps action) + +### Option 1 (recommended): Force Nexus APKINDEX rebuild + +Nexus admin UI / API → `repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz`: +```bash +# Trigger rebuild через Nexus API (требует admin token) +curl -X POST -u admin: \ + "https://repo.nstart.cloud/service/rest/v1/repositories/alpine/proxy//invalidate-cache" +``` + +Или через UI: `Repositories → alpine-proxy → Invalidate Cache`. + +После invalidate — следующий apk request триггерит fresh fetch upstream APKINDEX. + +### Option 2: Schedule periodic refresh + +Nexus settings → `Repositories → alpine → Connector → Maximum component age`. Set to `1 hour` (default может быть 24h+). + +Trade-off: чаще updates → больше upstream traffic. Для CI критично — рекомендую `1h` для APKINDEX, `7d` для package files (immutable). + +### Option 3 (last resort): Self-host Alpine mirror + +Если upstream Alpine periodically переписывает APKINDEX faster than Nexus refresh интервал — поднять dedicated mirror через `rsync` от tier-1 Alpine mirror каждые 30 мин. + +--- + +## Verification после fix + +1. Test mirror consistency: + ```bash + APK_CC=$(curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz \ + | tar xz -O APKINDEX | grep -A1 '^P:ca-certificates$' | tail -1 | sed 's/^V://') + FILE_CC=$(curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/ \ + | grep -o 'ca-certificates-[0-9]*-r[0-9]*' | head -1 | sed 's/ca-certificates-//') + [ "$APK_CC" = "$FILE_CC" ] && echo "OK: APKINDEX и files matching ($APK_CC)" \ + || echo "DRIFT: APKINDEX=$APK_CC files=$FILE_CC" + ``` + +2. Revert `node:22-alpine3.20` pin в `.gitlab-ci.yml` обратно на `node:22-alpine`: + ```diff + - image: repo.nstart.cloud/library/node:22-alpine3.20 + + image: repo.nstart.cloud/library/node:22-alpine + ``` + +3. Run release pipeline — должна complete без 404 в apk add. + +--- + +## Если воспроизводится снова + +Это второе incident за месяц (см. CI saga 2026-05-15 — `!227 → !229 → !230`). Нужен monitoring: + +```yaml +# Prometheus alert proposal +- alert: NexusAlpineMirrorDrift + expr: | + apk_index_version{repo="alpine-v3.23"} != apk_package_version{repo="alpine-v3.23", package="ca-certificates"} + for: 1h + labels: + severity: warning + annotations: + summary: "Nexus Alpine mirror v3.23 APKINDEX out of sync" + runbook: "docs-internal/ops/nexus-alpine-mirror.md" +``` + +(Метрики писать через cron job + node_exporter textfile collector — Nexus сам не отдаёт мониторинг APKINDEX consistency.) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index f8b1820..2d2a55f 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -776,6 +776,24 @@ export const NOTIFICATION_EVENT_TYPES = [ ] as const export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number] +/** + * Schema template summary (AI Schema Assist Phase 1, Approach A core). + * Curated skeleton schemas для quick start при создании нового справочника. + * Поле {@code icon} опционально — phosphor-icons name string. + */ +export type SchemaTemplateSummary = { + id: string + name: string + description: string + icon: string | null + tags: string[] +} + +/** Full schema template c JSON Schema body (lazy fetch when user picks). */ +export type SchemaTemplateDetail = SchemaTemplateSummary & { + schemaJson: unknown +} + /** * Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с * {@code validFrom > now AND validTo > now} в текущем scope view. diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 046c188..3c25b48 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -22,6 +22,8 @@ import { type ReviewQueuePage, type ScheduledRecordsSummary, type SchemaDependent, + type SchemaTemplateDetail, + type SchemaTemplateSummary, type SearchResponse, type WebhookDeliveryPage, type WebhookDeliveryStats, @@ -816,6 +818,40 @@ export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) => ...scheduledSummaryQuery(dictionaryName, scopeCsv), enabled: Boolean(dictionaryName), }) + +// ───────────────────────────────────────────────────────────────────────────── +// Schema templates (AI Schema Assist Phase 1, Approach A core) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Catalog of curated schema templates — lightweight summary без schemaJson. + * Stale time 5min — templates change только при deploy. + */ +export const schemaTemplatesQuery = queryOptions({ + queryKey: ['schema-templates'] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + '/dictionaries/templates', + ) + return data + }, + staleTime: 5 * 60_000, +}) + +export const useSchemaTemplates = () => useQuery(schemaTemplatesQuery) + +/** Detail fetch — lazy when user picks template. */ +export const schemaTemplateDetailQuery = (id: string) => + queryOptions({ + queryKey: ['schema-templates', id] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/dictionaries/templates/${id}`, + ) + return data + }, + staleTime: 5 * 60_000, + }) export const useRecordRaw = ( dictionaryName: string, businessKey: string | undefined, diff --git a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx index b4d2456..4f3c514 100644 --- a/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx +++ b/ordinis-admin-ui/src/components/schema/DictionaryEditorDialog.tsx @@ -19,6 +19,7 @@ import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations' import { dictionaryDetailQuery, useDictionaries } from '@/api/queries' import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client' import { SchemaBuilder } from './SchemaBuilder' +import { TemplatePicker } from './TemplatePicker' import { EventsPreviewTab } from './EventsPreviewTab' import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal' import { @@ -420,6 +421,23 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
+ {/* AI Schema Assist Phase 1: template picker visible только в create-mode + * И только пока пользователь не начал заполнять properties (чтобы не + * перезатереть его работу одним кликом). После первой property — + * picker исчезает (можно reset через template via existing dict templ + * UI выше или вручную clear). */} + {!isEdit && properties.length === 0 && ( +
+ { + const tplParsed = parseSchemaJson(template.schemaJson) + setProperties(tplParsed.properties) + setIdSource(tplParsed.idSource ?? '') + }} + /> +
+ )}
diff --git a/ordinis-admin-ui/src/components/schema/TemplatePicker.tsx b/ordinis-admin-ui/src/components/schema/TemplatePicker.tsx new file mode 100644 index 0000000..a8ac70f --- /dev/null +++ b/ordinis-admin-ui/src/components/schema/TemplatePicker.tsx @@ -0,0 +1,136 @@ +import { useTranslation } from 'react-i18next' +import { useQueryClient } from '@tanstack/react-query' +import { + BroadcastIcon, + BuildingsIcon, + FileDashedIcon, + RocketIcon, + WaveformIcon, + WifiHighIcon, + type Icon, +} from '@phosphor-icons/react' +import { LoadingBlock } from '@/ui' +import { + schemaTemplateDetailQuery, + useSchemaTemplates, +} from '@/api/queries' +import type { SchemaTemplateDetail, SchemaTemplateSummary } from '@/api/client' +import { cn } from '@/lib/utils' + +/** + * AI Schema Assist Phase 1 step 2 — curated template chooser. + * + *

Render'ит grid of template cards. Click card → fetch full schema lazy → + * fire {@code onPick(detail)}. Caller (DictionaryEditorDialog) применяет + * schemaJson через parseSchemaJson + setProperties. + * + *

Approach A (templates only) — works без LLM. Approach C upgrade + * (per-field AI suggest) — Phase 2, отдельный UI elsewhere. + * + *

Icon name из template metadata → resolved через {@link ICON_MAP}. + * Unknown icon → FileBlankIcon fallback. + */ + +const ICON_MAP: Record = { + // Template metadata icons mapped к phosphor-icons available в bundle. + // SatelliteIcon отсутствует в @phosphor-icons/react@2.1.10 → RocketIcon + // используется как proxy для space-domain templates. + SatelliteIcon: RocketIcon, + RocketIcon, + BroadcastIcon, + WifiHighIcon, + WaveformIcon, + BuildingsIcon, + FileBlankIcon: FileDashedIcon, + FileDashedIcon, +} + +const resolveIcon = (name: string | null): Icon => { + if (!name) return FileDashedIcon + return ICON_MAP[name] ?? FileDashedIcon +} + +type Props = { + /** Called когда юзер выбрал template — caller получает full detail с schemaJson. */ + onPick: (template: SchemaTemplateDetail) => void + /** Disable picker когда parent занят (mutation в полёте). */ + disabled?: boolean +} + +export function TemplatePicker({ onPick, disabled }: Props) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data: templates, isLoading, isError } = useSchemaTemplates() + + const handlePick = async (template: SchemaTemplateSummary) => { + if (disabled) return + try { + const detail = await queryClient.fetchQuery( + schemaTemplateDetailQuery(template.id), + ) + onPick(detail) + } catch { + // Quiet fail — caller ничего не получает, юзер видит no change. + // Real error случится только если template исчез между list и detail + // fetch (deploy в гонке). Caller справится через retry. + } + } + + if (isLoading) return + if (isError || !templates || templates.length === 0) return null + + return ( +

+
+ {t('schemaTemplates.label', { defaultValue: 'Начать с шаблона' })} +
+
+ {templates.map((template) => { + const IconComp = resolveIcon(template.icon) + return ( + + ) + })} +
+
+ {t('schemaTemplates.hint', { + defaultValue: 'Шаблон загрузит начальные поля. Дополни их вручную.', + })} +
+
+ ) +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/antenna.template.json b/ordinis-cuod-bundle/src/main/resources/templates/antenna.template.json new file mode 100644 index 0000000..0664cd7 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/antenna.template.json @@ -0,0 +1,41 @@ +{ + "$comment": "Template: antenna — minimal skeleton с FK на ground_station", + "x-template-id": "antenna", + "x-template-name": "Антенна", + "x-template-description": "Скелет: код, имя, диаметр, FK на наземную станцию. Диапазоны и G/T добавь сам.", + "x-template-icon": "WifiHighIcon", + "x-template-tags": ["space", "hardware", "cuod"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code", "name", "ground_station"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Уникальный код антенны", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + }, + "name": { + "type": "object", + "x-localized": true, + "description": "Наименование антенны", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 } + }, + "required": ["ru-RU"] + }, + "diameter_m": { + "type": "number", + "description": "Диаметр зеркала, м", + "minimum": 0 + }, + "ground_station": { + "type": "string", + "description": "FK на ground_station.code", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-references": "ground_station.code" + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/blank.template.json b/ordinis-cuod-bundle/src/main/resources/templates/blank.template.json new file mode 100644 index 0000000..f5af17b --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/blank.template.json @@ -0,0 +1,21 @@ +{ + "$comment": "Template: blank starter", + "x-template-id": "blank", + "x-template-name": "Пустой каркас", + "x-template-description": "Только бизнес-ключ (code). Все остальные поля добавь сам.", + "x-template-icon": "FileBlankIcon", + "x-template-tags": ["empty", "starter"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Бизнес-ключ", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/frequency-band.template.json b/ordinis-cuod-bundle/src/main/resources/templates/frequency-band.template.json new file mode 100644 index 0000000..e60e298 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/frequency-band.template.json @@ -0,0 +1,40 @@ +{ + "$comment": "Template: frequency-band — min/max МГц + band code", + "x-template-id": "frequency-band", + "x-template-name": "Диапазон частот", + "x-template-description": "Скелет: код, имя, нижняя/верхняя частота (МГц).", + "x-template-icon": "WaveformIcon", + "x-template-tags": ["radio", "cuod"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code", "name", "min_mhz", "max_mhz"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Код диапазона (e.g. L_BAND)", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + }, + "name": { + "type": "object", + "x-localized": true, + "description": "Наименование", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 } + }, + "required": ["ru-RU"] + }, + "min_mhz": { + "type": "number", + "description": "Нижняя частота, МГц", + "minimum": 0 + }, + "max_mhz": { + "type": "number", + "description": "Верхняя частота, МГц", + "minimum": 0 + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/ground-station.template.json b/ordinis-cuod-bundle/src/main/resources/templates/ground-station.template.json new file mode 100644 index 0000000..1d06c6e --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/ground-station.template.json @@ -0,0 +1,47 @@ +{ + "$comment": "Template: ground-station — minimal skeleton с lat/lon", + "x-template-id": "ground-station", + "x-template-name": "Наземная станция", + "x-template-description": "Скелет: код, имя, координаты, страна. Антенны и оборудование добавь сам.", + "x-template-icon": "BroadcastIcon", + "x-template-tags": ["space", "ground", "cuod"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code", "name", "lat", "lon"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Уникальный код станции", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + }, + "name": { + "type": "object", + "x-localized": true, + "description": "Наименование станции", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 } + }, + "required": ["ru-RU"] + }, + "lat": { + "type": "number", + "description": "Широта (°)", + "minimum": -90, + "maximum": 90 + }, + "lon": { + "type": "number", + "description": "Долгота (°)", + "minimum": -180, + "maximum": 180 + }, + "country": { + "type": "string", + "description": "ISO 3166-1 alpha-2 страны размещения", + "pattern": "^[A-Z]{2}$" + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/operator.template.json b/ordinis-cuod-bundle/src/main/resources/templates/operator.template.json new file mode 100644 index 0000000..884f4ed --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/operator.template.json @@ -0,0 +1,40 @@ +{ + "$comment": "Template: operator (оператор КА / земсегмента)", + "x-template-id": "operator", + "x-template-name": "Оператор", + "x-template-description": "Скелет: код, имя, страна, контакт. Лицензии и контракты добавь сам.", + "x-template-icon": "BuildingsIcon", + "x-template-tags": ["org", "cuod"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code", "name", "country"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Уникальный код оператора", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + }, + "name": { + "type": "object", + "x-localized": true, + "description": "Наименование оператора", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 } + }, + "required": ["ru-RU"] + }, + "country": { + "type": "string", + "description": "ISO 3166-1 alpha-2 страны", + "pattern": "^[A-Z]{2}$" + }, + "contact_email": { + "type": "string", + "format": "email", + "description": "Контактный email" + } + } +} diff --git a/ordinis-cuod-bundle/src/main/resources/templates/spacecraft.template.json b/ordinis-cuod-bundle/src/main/resources/templates/spacecraft.template.json new file mode 100644 index 0000000..4d31fa7 --- /dev/null +++ b/ordinis-cuod-bundle/src/main/resources/templates/spacecraft.template.json @@ -0,0 +1,41 @@ +{ + "$comment": "Template: spacecraft (ЦУОД) — minimal skeleton", + "x-template-id": "spacecraft", + "x-template-name": "Космический аппарат", + "x-template-description": "Скелет для справочника КА: код, имя, тип, страна. Орбиту/мaccу/спектр добавь сам.", + "x-template-icon": "SatelliteIcon", + "x-template-tags": ["space", "cuod"], + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "x-id-source": "code", + "required": ["code", "name"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "Уникальный код КА", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-unique": true + }, + "name": { + "type": "object", + "x-localized": true, + "description": "Наименование КА", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 } + }, + "required": ["ru-RU"] + }, + "satellite_type_code": { + "type": "string", + "description": "Тип КА (FK на satellite_type)", + "pattern": "^[A-Z][A-Z0-9_]{1,31}$", + "x-references": "satellite_type.code" + }, + "country": { + "type": "string", + "description": "ISO 3166-1 alpha-2 страны-оператора", + "pattern": "^[A-Z]{2}$" + } + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/template/SchemaTemplateRegistry.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/template/SchemaTemplateRegistry.java new file mode 100644 index 0000000..2cd008b --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/template/SchemaTemplateRegistry.java @@ -0,0 +1,143 @@ +package cloud.nstart.terravault.ordinis.restapi.service.template; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * AI Schema Assist — Phase 1 step 1: curated schema templates registry. + * + *

Scans classpath {@code templates/*.template.json} at startup. Каждый файл — + * полная JSON Schema + дополнительные {@code x-template-*} metadata header + * (id/name/description/icon/tags) для каталога UI. + * + *

Approach A (templates only) core. Pure offline — no LLM dep. Templates + * ship as part of {@code ordinis-cuod-bundle}; другие bundles могут добавить + * свои templates в собственные {@code resources/templates/} — scanner picks + * them all up (classpath wildcard). + * + *

Approach C upgrade path: AI suggest-field будет принимать template'у как + * starting context и предлагать additional fields — но это Phase 1 step 4-6, + * не сейчас. + * + *

Templates immutable per deploy — reload требует restart. Acceptable + * trade-off: templates меняются редко, hot-reload добавляет complexity без + * value. + */ +@Service +public class SchemaTemplateRegistry { + + private static final Logger log = LoggerFactory.getLogger(SchemaTemplateRegistry.class); + private static final String TEMPLATES_PATTERN = "classpath*:templates/*.template.json"; + + private final ObjectMapper mapper; + private final ResourcePatternResolver resourceResolver; + private final Map templatesById = new LinkedHashMap<>(); + + public SchemaTemplateRegistry(ObjectMapper mapper) { + this.mapper = mapper; + this.resourceResolver = new PathMatchingResourcePatternResolver(); + } + + @PostConstruct + void loadTemplates() { + try { + Resource[] resources = resourceResolver.getResources(TEMPLATES_PATTERN); + for (Resource resource : resources) { + try (var is = resource.getInputStream()) { + JsonNode root = mapper.readTree(is); + SchemaTemplate tpl = parseTemplate(root, resource.getFilename()); + if (tpl != null) { + templatesById.put(tpl.id(), tpl); + } + } catch (Exception e) { + log.warn("Skipping malformed template {}: {}", resource.getFilename(), e.getMessage()); + } + } + log.info("Loaded {} schema templates: {}", templatesById.size(), templatesById.keySet()); + } catch (IOException e) { + log.error("Failed to scan templates classpath", e); + } + } + + private SchemaTemplate parseTemplate(JsonNode root, String fileName) { + String id = textOrNull(root, "x-template-id"); + if (id == null || id.isBlank()) { + log.warn("Template {} missing x-template-id — skipping", fileName); + return null; + } + String name = Optional.ofNullable(textOrNull(root, "x-template-name")).orElse(id); + String description = Optional.ofNullable(textOrNull(root, "x-template-description")).orElse(""); + String icon = textOrNull(root, "x-template-icon"); + + List tags = new ArrayList<>(); + JsonNode tagsNode = root.get("x-template-tags"); + if (tagsNode != null && tagsNode.isArray()) { + tagsNode.forEach(n -> { if (n.isTextual()) tags.add(n.asText()); }); + } + + // Stripped schema = template без x-template-* metadata (clean JSON Schema + // готова к загрузке в Monaco / dictionary creation). Keep $schema, type, + // required, properties, x-id-source — это canonical schema content. + var stripped = root.deepCopy(); + if (stripped.isObject()) { + var obj = (com.fasterxml.jackson.databind.node.ObjectNode) stripped; + obj.remove("x-template-id"); + obj.remove("x-template-name"); + obj.remove("x-template-description"); + obj.remove("x-template-icon"); + obj.remove("x-template-tags"); + obj.remove("$comment"); + } + + return new SchemaTemplate( + id, name, description, icon, + Collections.unmodifiableList(tags), + stripped); + } + + private static String textOrNull(JsonNode root, String field) { + JsonNode n = root.get(field); + return (n != null && n.isTextual()) ? n.asText() : null; + } + + public List all() { + return new ArrayList<>(templatesById.values()); + } + + public Optional byId(String id) { + return Optional.ofNullable(templatesById.get(id)); + } + + /** + * Schema template metadata + cleaned schema JSON. + * + * @param id unique template identifier (matches filename prefix usually) + * @param name human-readable name (RU/EN agnostic — admin sees it) + * @param description short blurb что внутри + что admin должен дополнить + * @param icon optional phosphor-icons icon name (e.g. "SatelliteIcon") + * @param tags tags для filtering в UI (e.g. ["space", "cuod"]) + * @param schemaJson canonical JSON Schema (без x-template-* metadata) + */ + public record SchemaTemplate( + String id, + String name, + String description, + String icon, + List tags, + JsonNode schemaJson) {} +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaTemplatesController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaTemplatesController.java new file mode 100644 index 0000000..a714ac1 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaTemplatesController.java @@ -0,0 +1,69 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.restapi.service.template.SchemaTemplateRegistry; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +/** + * AI Schema Assist Phase 1 — templates listing endpoint. + * + *

{@code GET /api/v1/dictionaries/templates} — каталог доступных шаблонов + * без полной schema (lightweight для grid rendering). + * + *

{@code GET /api/v1/dictionaries/templates/{id}} — полная JSON Schema + * для выбранного шаблона (lazy load когда admin кликнул). + * + *

Public endpoint (auth optional) — templates сами по себе не sensitive, + * это shared catalog для admins. RBAC будет gate'нуть actual dict creation + * (downstream), а здесь только preview. + */ +@RestController +@RequestMapping("/api/v1/dictionaries/templates") +public class SchemaTemplatesController { + + private final SchemaTemplateRegistry registry; + + public SchemaTemplatesController(SchemaTemplateRegistry registry) { + this.registry = registry; + } + + /** Catalog — без schemaJson (экономит ~3KB на шаблон в list view). */ + @GetMapping + public List list() { + return registry.all().stream() + .map(t -> new TemplateSummary(t.id(), t.name(), t.description(), t.icon(), t.tags())) + .toList(); + } + + /** Detail — full JSON Schema готовая к загрузке в Monaco. */ + @GetMapping("/{id}") + public TemplateDetail get(@PathVariable String id) { + return registry.byId(id) + .map(t -> new TemplateDetail( + t.id(), t.name(), t.description(), t.icon(), t.tags(), t.schemaJson())) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.NOT_FOUND, "Template not found: " + id)); + } + + public record TemplateSummary( + String id, + String name, + String description, + String icon, + List tags) {} + + public record TemplateDetail( + String id, + String name, + String description, + String icon, + List tags, + JsonNode schemaJson) {} +}