Merge branch 'feat/ai-schema-suggest' into 'main'

feat(ai): Phase 1 step 3-6 — LLM adapter + per-field AI suggest endpoint + UI

See merge request 2-6/2-6-4/terravault/ordinis!234
This commit is contained in:
Александр Зимин
2026-05-16 16:25:19 +00:00
10 changed files with 861 additions and 1 deletions
+9
View File
@@ -794,6 +794,15 @@ export type SchemaTemplateDetail = SchemaTemplateSummary & {
schemaJson: unknown
}
/**
* AI Schema Assist suggest-field response. Shape должен matchить
* AiSchemaController output (parsed LLM JSON: {fieldName, schema}).
*/
export type AiFieldSuggestion = {
fieldName: string
schema: Record<string, unknown>
}
/**
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
* {@code validFrom > now AND validTo > now} в текущем scope view.
+26
View File
@@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
apiClient,
type AiFieldSuggestion,
type BulkCloseRequest,
type BulkCloseResponse,
type CascadeCloseResult,
@@ -741,3 +742,28 @@ export const useUpdateNotificationPreferences = () => {
},
})
}
/**
* AI Schema Assist — per-field suggest. Returns parsed {fieldName, schema}.
* Каллер показывает diff preview, user accept/edit/reject.
*
* 404 → AI disabled на бэкенде — frontend hides button (handled в caller).
* 503/circuit_open → temporary unavailable, show banner.
* 422 → bad output from LLM — show error message, suggest retry.
* 429 → rate limit, show "try again in a minute".
*/
export const useAiSuggestField = () => {
return useMutation({
mutationFn: async (req: {
existingSchema: unknown
prompt: string
}): Promise<AiFieldSuggestion> => {
const { data } = await apiClient.post<AiFieldSuggestion>(
'/ai/suggest-field',
req,
{ timeout: 30_000 }, // override default 10s — LLM может быть slow
)
return data
},
})
}
+28
View File
@@ -852,6 +852,34 @@ export const schemaTemplateDetailQuery = (id: string) =>
},
staleTime: 5 * 60_000,
})
// ─────────────────────────────────────────────────────────────────────────────
// AI feature detection — probe endpoint
// ─────────────────────────────────────────────────────────────────────────────
/**
* Probe AI Schema Assist availability. Backend регистрирует /ai/suggest-field
* conditionally на ordinis.ai.enabled — если выключено, OPTIONS возвращает 404.
*
* <p>Used UI чтобы decide показывать ли «AI suggest» button. Cache 5 min —
* feature flag меняется только при helm upgrade.
*/
export const aiFeatureAvailableQuery = queryOptions({
queryKey: ['ai-feature-available'] as const,
queryFn: async (): Promise<boolean> => {
try {
const { data } = await apiClient.get<{ enabled: boolean }>('/ai/info')
return Boolean(data?.enabled)
} catch {
// 404 (AI disabled) или network error → hide button (graceful default).
return false
}
},
staleTime: 5 * 60_000,
retry: false,
})
export const useAiFeatureAvailable = () => useQuery(aiFeatureAvailableQuery)
export const useRecordRaw = (
dictionaryName: string,
businessKey: string | undefined,
@@ -0,0 +1,199 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import { SparkleIcon, XIcon } from '@phosphor-icons/react'
import { Alert, Button, TextArea } from '@/ui'
import { useAiSuggestField } from '@/api/mutations'
import { cn } from '@/lib/utils'
/**
* AI Schema Assist — per-field suggest panel.
*
* <p>Expanded inline (не modal) — admin вводит описание → preview JSON suggestion
* → accept/reject. Accept callback получает {fieldName, schema} как plain JSON
* fragment, caller интегрирует в свой schema state.
*
* <p>Graceful degradation:
* <ul>
* <li>404 от endpoint (AI disabled на бэке) → caller должен hide button entirely</li>
* <li>503 circuit_open → banner «AI временно недоступен», retry в 5 мин</li>
* <li>422 bad_output → error message, suggest другой prompt</li>
* <li>429 rate_limit → «слишком много запросов, подожди минуту»</li>
* </ul>
*/
type Props = {
/** Current schema serialized as JSON object — будет передан как context */
existingSchema: Record<string, unknown>
/** Called когда юзер accepted suggestion */
onAccept: (suggestion: { fieldName: string; schema: Record<string, unknown> }) => void
/** Optional close handler — hide panel */
onClose?: () => void
}
export function AiFieldSuggestPanel({ existingSchema, onAccept, onClose }: Props) {
const { t } = useTranslation()
const [prompt, setPrompt] = useState('')
const [suggestion, setSuggestion] = useState<{
fieldName: string
schema: Record<string, unknown>
} | null>(null)
const mutation = useAiSuggestField()
const handleSuggest = () => {
if (!prompt.trim()) return
setSuggestion(null)
mutation.mutate(
{ existingSchema, prompt },
{
onSuccess: (data) => setSuggestion(data),
},
)
}
const handleAccept = () => {
if (!suggestion) return
onAccept(suggestion)
setPrompt('')
setSuggestion(null)
}
const handleRetry = () => {
setSuggestion(null)
handleSuggest()
}
const errorMessage = mutation.error ? formatError(mutation.error, t) : null
return (
<div className="flex flex-col gap-3 p-4 rounded-md border border-line bg-surface-2/50">
<div className="flex items-center gap-2">
<SparkleIcon size={16} weight="fill" className="text-accent shrink-0" />
<span className="text-body font-semibold text-ink flex-1">
{t('aiSuggest.title', { defaultValue: 'AI: добавить поле' })}
</span>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label={t('common.close', { defaultValue: 'Закрыть' })}
className="text-mute hover:text-ink p-1 rounded-sm hover:bg-surface"
>
<XIcon size={14} />
</button>
)}
</div>
<div className="flex flex-col gap-1">
<TextArea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={t('aiSuggest.placeholder', {
defaultValue: 'Опиши поле — например "орбита: апогей, перигей, наклонение"',
})}
rows={2}
disabled={mutation.isPending}
maxLength={500}
/>
<div className="text-cell text-mute">
{t('aiSuggest.hint', {
defaultValue: 'AI предложит структуру (type/format/x-references). GOST коды и validations добавь сам.',
})}
</div>
</div>
{!suggestion && (
<div className="flex justify-end">
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSuggest}
disabled={mutation.isPending || !prompt.trim()}
>
{mutation.isPending
? t('aiSuggest.thinking', { defaultValue: 'Думаю…' })
: t('aiSuggest.suggest', { defaultValue: 'Предложить' })}
</Button>
</div>
)}
{errorMessage && (
<Alert variant="error">{errorMessage}</Alert>
)}
{suggestion && (
<div className="flex flex-col gap-2">
<div className="text-cap text-mute uppercase tracking-wider">
{t('aiSuggest.preview', { defaultValue: 'Предложение' })}
</div>
<div className="rounded-sm border border-line bg-surface p-3">
<div className="text-body font-semibold text-ink mb-1">
<span className="text-mono text-accent">{suggestion.fieldName}</span>
</div>
<pre className={cn(
'text-mono text-cell text-ink-2 max-h-64 overflow-auto whitespace-pre-wrap',
'bg-surface-2 rounded-sm p-2',
)}>
{JSON.stringify(suggestion.schema, null, 2)}
</pre>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleRetry}
disabled={mutation.isPending}
>
{t('aiSuggest.retry', { defaultValue: 'Перегенерировать' })}
</Button>
<Button
type="button"
variant="primary"
size="sm"
onClick={handleAccept}
disabled={mutation.isPending}
>
{t('aiSuggest.accept', { defaultValue: 'Добавить в схему' })}
</Button>
</div>
</div>
)}
</div>
)
}
function formatError(
err: unknown,
t: (k: string, opts?: Record<string, unknown>) => string,
): string {
if (axios.isAxiosError(err)) {
const status = err.response?.status
if (status === 503) {
return t('aiSuggest.error.circuit', {
defaultValue: 'AI временно недоступен. Попробуй через несколько минут.',
})
}
if (status === 429) {
return t('aiSuggest.error.rate', {
defaultValue: 'Слишком много запросов. Подожди минуту.',
})
}
if (status === 422) {
return t('aiSuggest.error.bad_output', {
defaultValue: 'AI вернул невалидный ответ. Попробуй переформулировать prompt.',
})
}
if (status === 502) {
return t('aiSuggest.error.upstream', {
defaultValue: 'LLM endpoint недоступен. Жди restore или попробуй позже.',
})
}
if (status === 404) {
return t('aiSuggest.error.disabled', {
defaultValue: 'AI отключён в этой инсталляции.',
})
}
}
return t('aiSuggest.error.generic', { defaultValue: 'Не удалось получить предложение.' })
}
@@ -16,10 +16,16 @@ import {
type TabItem,
} from '@/ui'
import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
import {
dictionaryDetailQuery,
useAiFeatureAvailable,
useDictionaries,
} from '@/api/queries'
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
import { SchemaBuilder } from './SchemaBuilder'
import { TemplatePicker } from './TemplatePicker'
import { AiFieldSuggestPanel } from './AiFieldSuggestPanel'
import { SparkleIcon } from '@phosphor-icons/react'
import { EventsPreviewTab } from './EventsPreviewTab'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import {
@@ -127,6 +133,9 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
* into the schema-draft modal with the maker's in-progress schemaJson
* pre-loaded no retyping. */
const [draftHandoffOpen, setDraftHandoffOpen] = useState(false)
/** AI Schema Assist (Phase 1 step 6): per-field LLM suggest panel toggle. */
const [aiPanelOpen, setAiPanelOpen] = useState(false)
const aiAvailable = useAiFeatureAvailable()
// Template (только в create-mode): admin выбирает существующий dict,
// его schema + locale + scope копируются. Имя остаётся пустым (admin
@@ -438,6 +447,51 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
/>
</div>
)}
{/* AI Schema Assist Phase 1 step 6: per-field LLM suggest panel.
* Toggle hidden когда ordinis.ai.enabled=false на бэкенде (probe
* via /api/v1/ai/info 404 = hide). */}
{aiAvailable.data && (
<div className="mb-4">
{!aiPanelOpen ? (
<button
type="button"
onClick={() => setAiPanelOpen(true)}
className="inline-flex items-center gap-1.5 text-cell text-accent hover:underline"
>
<SparkleIcon size={14} weight="fill" />
{t('aiSuggest.openButton', { defaultValue: 'AI: добавить поле' })}
</button>
) : (
<AiFieldSuggestPanel
existingSchema={schemaJson as Record<string, unknown>}
onClose={() => setAiPanelOpen(false)}
onAccept={(suggestion) => {
// Wrap suggestion в mini-schema → parse через existing
// parseSchemaJson → получаем PropertyDef. Reuse уже
// tested kind-inference logic.
const wrapper = {
type: 'object',
properties: { [suggestion.fieldName]: suggestion.schema },
}
const parsed = parseSchemaJson(wrapper as never)
if (parsed.properties.length > 0) {
// Avoid duplicate field name — overwrite если уже есть.
const existingIdx = properties.findIndex(
(p) => p.name === suggestion.fieldName,
)
if (existingIdx >= 0) {
const next = [...properties]
next[existingIdx] = parsed.properties[0]
setProperties(next)
} else {
setProperties([...properties, parsed.properties[0]])
}
}
}}
/>
)}
</div>
)}
<SchemaBuilder properties={properties} onChange={setProperties} />
</div>
+36
View File
@@ -370,6 +370,24 @@ i18n
'dict.scheduled.cta': 'Перейти к этой дате',
'dict.scheduled.row.badge': 'Запланировано',
'dict.scheduled.row.tooltip': 'У записи есть запланированная будущая версия',
'aiSuggest.openButton': 'AI: добавить поле',
'aiSuggest.title': 'AI: добавить поле',
'aiSuggest.placeholder': 'Опиши поле — например «орбита: апогей, перигей, наклонение»',
'aiSuggest.hint': 'AI предложит структуру (type/format/x-references). GOST коды и validations добавь сам.',
'aiSuggest.suggest': 'Предложить',
'aiSuggest.thinking': 'Думаю…',
'aiSuggest.preview': 'Предложение',
'aiSuggest.retry': 'Перегенерировать',
'aiSuggest.accept': 'Добавить в схему',
'aiSuggest.error.circuit': 'AI временно недоступен. Попробуй через несколько минут.',
'aiSuggest.error.rate': 'Слишком много запросов. Подожди минуту.',
'aiSuggest.error.bad_output': 'AI вернул невалидный ответ. Попробуй переформулировать.',
'aiSuggest.error.upstream': 'LLM endpoint недоступен. Жди restore или попробуй позже.',
'aiSuggest.error.disabled': 'AI отключён в этой инсталляции.',
'aiSuggest.error.generic': 'Не удалось получить предложение.',
'schemaTemplates.label': 'Начать с шаблона',
'schemaTemplates.hint': 'Шаблон загрузит начальные поля. Дополни их вручную.',
'common.close': 'Закрыть',
'dict.col.businessKey': 'Бизнес-ключ',
'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Действует с',
@@ -1189,6 +1207,24 @@ i18n
'dict.scheduled.cta': 'Jump to that date',
'dict.scheduled.row.badge': 'Scheduled',
'dict.scheduled.row.tooltip': 'This record has an upcoming scheduled version',
'aiSuggest.openButton': 'AI: add field',
'aiSuggest.title': 'AI: add field',
'aiSuggest.placeholder': 'Describe the field — e.g. "orbit: apogee, perigee, inclination"',
'aiSuggest.hint': 'AI suggests structure (type/format/x-references). Add GOST codes and validations manually.',
'aiSuggest.suggest': 'Suggest',
'aiSuggest.thinking': 'Thinking…',
'aiSuggest.preview': 'Suggestion',
'aiSuggest.retry': 'Regenerate',
'aiSuggest.accept': 'Add to schema',
'aiSuggest.error.circuit': 'AI temporarily unavailable. Try again in a few minutes.',
'aiSuggest.error.rate': 'Too many requests. Wait a minute.',
'aiSuggest.error.bad_output': 'AI returned invalid response. Try rephrasing.',
'aiSuggest.error.upstream': 'LLM endpoint unreachable. Wait or try later.',
'aiSuggest.error.disabled': 'AI is disabled in this installation.',
'aiSuggest.error.generic': 'Failed to get suggestion.',
'schemaTemplates.label': 'Start from template',
'schemaTemplates.hint': 'Template loads initial fields. Extend manually.',
'common.close': 'Close',
'dict.col.businessKey': 'Business key',
'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Valid from',
@@ -230,6 +230,18 @@ ordinis:
require-authentication: ${ORDINIS_AUTH_REQUIRED:false}
allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true}
# AI Schema Assist (Phase 1 step 3-6). Off by default — controllers/services
# bean-conditionally registered, frontend hides AI button when 404.
# Enable per-env через helm values (см. charts/ordinis/values-staging.yaml).
# endpoint: OpenAI-compatible /v1/chat/completions root (без /v1 suffix —
# adapter добавит сам). bearer-token optional (Ollama не требует, OpenAI да).
ai:
enabled: ${ORDINIS_AI_ENABLED:false}
endpoint: ${ORDINIS_AI_ENDPOINT:}
model: ${ORDINIS_AI_MODEL:qwen2.5:7b}
bearer-token: ${ORDINIS_AI_BEARER_TOKEN:}
timeout-seconds: ${ORDINIS_AI_TIMEOUT_SECONDS:15}
# Keycloak Admin REST integration for UserDisplayService Phase 2 — on-demand
# sub→user lookup when JWT capture cache + DB cache both miss. See
# KeycloakAdminUserResolver.java + 0023-user-display-cache.xml migration.
@@ -0,0 +1,206 @@
package cloud.nstart.terravault.ordinis.restapi.service.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AI Schema Assist per-field LLM suggest service (Approach C core).
*
* <p>Принимает existing JSON Schema (context) + free-form prompt (что добавить)
* возвращает {@code {fieldName, schema}} suggestion. Admin reviews, accepts
* или edits через standard draft flow.
*
* <p>System prompt enforces:
* <ul>
* <li>Single field output (не whole schema regen)</li>
* <li>Use x-localized / x-references / x-unique / format где уместно</li>
* <li>НЕ изобретать GOST коды или business validations</li>
* </ul>
*
* <p>Circuit breaker (in-memory): 10 fails in 60s reject все calls в течение
* 5 min. Reset на первом успешном после window. Защита от GPU outage.
*/
@Service
@ConditionalOnBean(LlmAdapter.class)
public class AiSchemaService {
private static final Logger log = LoggerFactory.getLogger(AiSchemaService.class);
private static final String SYSTEM_PROMPT = """
Ты помогаешь админу справочников ДЗЗ строить JSON Schema fields.
Получаешь existing schema (текущие поля) + prompt (описание нового поля).
Возвращаешь СТРОГО валидный JSON с двумя ключами: fieldName, schema.
Правила:
- fieldName snake_case, латиница, без префиксов/dots
- schema JSON Schema fragment для ОДНОГО поля (один root type)
- x-localized: true для локализованных текстов (multi-language)
- x-references: "dict.field" для FK на другой справочник
- x-unique: true для бизнес-ключей
- format: date / date-time / email / uri где уместно
- description: краткий русский текст что означает поле
НЕ ИЗОБРЕТАЙ:
- GOST коды или иные стандартизованные codes
- Business validations (только structural type/format/min/max)
- Имена существующих справочников (если не уверен в имени verbal hint в description без x-references)
Возвращай ТОЛЬКО JSON, без markdown fences, без комментариев, без преамбулы.
""";
private static final String FEW_SHOT_EXAMPLE = """
Пример 1.
Existing: {"properties":{"code":{"type":"string"},"name":{"type":"object"}}}
Prompt: "Орбита — апогей, перигей, наклонение в градусах"
Output: {"fieldName":"orbit","schema":{"type":"object","description":"Параметры орбиты","properties":{"apogee_km":{"type":"number","description":"Апогей, км","minimum":0},"perigee_km":{"type":"number","description":"Перигей, км","minimum":0},"inclination_deg":{"type":"number","minimum":0,"maximum":180}}}}
Пример 2.
Existing: {"properties":{"code":{"type":"string"},"name":{"type":"object"}}}
Prompt: "Email контакт"
Output: {"fieldName":"contact_email","schema":{"type":"string","format":"email","description":"Контактный email"}}
Пример 3.
Existing: {"properties":{"code":{"type":"string"}}}
Prompt: "Ссылка на оператора"
Output: {"fieldName":"operator_code","schema":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{1,31}$","x-references":"operator.code","description":"FK на operator.code"}}
""";
private final LlmAdapter llm;
private final ObjectMapper mapper;
// Simple in-memory circuit breaker счётчик fails за rolling 60s window,
// если >= 10 open для 5 min. State per JVM (writer single instance).
private final AtomicInteger recentFails = new AtomicInteger(0);
private final AtomicLong windowStartedAt = new AtomicLong(System.currentTimeMillis());
private final AtomicLong openUntil = new AtomicLong(0);
private static final long WINDOW_MS = 60_000;
private static final int FAIL_THRESHOLD = 10;
private static final long OPEN_DURATION_MS = 5 * 60_000;
public AiSchemaService(LlmAdapter llm, ObjectMapper mapper) {
this.llm = llm;
this.mapper = mapper;
}
/**
* Suggest single field. Returns parsed {@code {fieldName, schema}} JsonNode.
*
* @throws AiSchemaException на circuit breaker open, LLM error, или invalid output
*/
public JsonNode suggestField(JsonNode existingSchema, String prompt) throws AiSchemaException {
if (isOpen()) {
throw new AiSchemaException("AI временно недоступен (circuit breaker open)", "circuit_open");
}
if (prompt == null || prompt.isBlank()) {
throw new AiSchemaException("prompt пустой", "bad_prompt");
}
if (prompt.length() > 500) {
throw new AiSchemaException("prompt слишком длинный (max 500 chars)", "bad_prompt");
}
String existingJson = existingSchema == null ? "{\"properties\":{}}" : existingSchema.toString();
if (existingJson.length() > 4000) {
// Truncate context if monstrous каркасные templates обычно ~500 chars.
log.warn("existingSchema truncated from {} to 4000 chars", existingJson.length());
existingJson = existingJson.substring(0, 4000);
}
String userPrompt = FEW_SHOT_EXAMPLE
+ "\n\nТеперь твой ход.\nExisting: " + existingJson
+ "\nPrompt: \"" + prompt.replace("\"", "\\\"") + "\"\nOutput:";
String raw;
try {
raw = llm.chat(SYSTEM_PROMPT, userPrompt);
} catch (LlmAdapter.LlmException e) {
recordFail();
throw new AiSchemaException(e.getMessage(), "llm_error");
}
JsonNode parsed = parseFieldSuggestion(raw);
if (parsed == null) {
recordFail();
throw new AiSchemaException(
"LLM вернул невалидный JSON: " + truncate(raw, 200), "bad_output");
}
if (!parsed.has("fieldName") || !parsed.has("schema")) {
recordFail();
throw new AiSchemaException(
"LLM output missing fieldName/schema keys", "bad_output");
}
String fieldName = parsed.get("fieldName").asText("");
if (!fieldName.matches("^[a-z][a-z0-9_]{0,63}$")) {
recordFail();
throw new AiSchemaException(
"fieldName '" + fieldName + "' не валидный (snake_case, латиница, ≤64)", "bad_field_name");
}
// Success reset breaker counter.
recentFails.set(0);
return parsed;
}
/**
* Try parse LLM output as JSON. Robust к markdown fences (```json ... ```)
* которые small models иногда добавляют несмотря на system prompt.
*/
JsonNode parseFieldSuggestion(String raw) {
if (raw == null) return null;
String cleaned = raw.trim();
// Strip markdown code fence
if (cleaned.startsWith("```")) {
Matcher m = Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```", Pattern.MULTILINE)
.matcher(cleaned);
if (m.find()) cleaned = m.group(1).trim();
}
try {
return mapper.readTree(cleaned);
} catch (Exception e) {
return null;
}
}
private boolean isOpen() {
return openUntil.get() > System.currentTimeMillis();
}
private void recordFail() {
long now = System.currentTimeMillis();
if (now - windowStartedAt.get() > WINDOW_MS) {
// Reset rolling window
windowStartedAt.set(now);
recentFails.set(1);
return;
}
int fails = recentFails.incrementAndGet();
if (fails >= FAIL_THRESHOLD) {
openUntil.set(now + OPEN_DURATION_MS);
log.warn("AI circuit breaker OPEN after {} fails in {}ms — closed until {}ms",
fails, WINDOW_MS, openUntil.get());
}
}
private static String truncate(String s, int max) {
return s.length() <= max ? s : s.substring(0, max) + "";
}
/** Domain exception with stable error code для frontend (i18n + error UX). */
public static class AiSchemaException extends Exception {
private final String code;
public AiSchemaException(String message, String code) {
super(message);
this.code = code;
}
public String getCode() { return code; }
}
}
@@ -0,0 +1,160 @@
package cloud.nstart.terravault.ordinis.restapi.service.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
/**
* AI Schema Assist thin HTTP client для OpenAI-compatible chat completion
* endpoints (Ollama via `/v1/chat/completions`, vLLM, OpenAI itself).
*
* <p>Минимальный subset: model + messages assistant content. Без streaming,
* без tool calls для AI Schema Assist достаточно single-shot JSON gen.
*
* <p>Bean создаётся ТОЛЬКО когда {@code ordinis.ai.enabled=true}
* controllers/services которые требуют LlmAdapter должны быть guarded тем же
* flag'ом. Это позволяет run prod без AI deps когда GPU не готов / license off.
*
* <p>Bearer token optional (Ollama works без auth; OpenAI требует).
*/
@Component
@ConditionalOnProperty(name = "ordinis.ai.enabled", havingValue = "true")
public class LlmAdapter {
private static final Logger log = LoggerFactory.getLogger(LlmAdapter.class);
private final HttpClient http;
private final ObjectMapper mapper;
private final String endpoint;
private final String model;
private final String bearerToken;
private final Duration timeout;
public LlmAdapter(
ObjectMapper mapper,
@Value("${ordinis.ai.endpoint:}") String endpoint,
@Value("${ordinis.ai.model:qwen2.5:7b}") String model,
@Value("${ordinis.ai.bearer-token:}") String bearerToken,
@Value("${ordinis.ai.timeout-seconds:15}") int timeoutSeconds) {
this.mapper = mapper;
this.endpoint = trimTrailingSlash(endpoint);
this.model = model;
this.bearerToken = bearerToken;
this.timeout = Duration.ofSeconds(timeoutSeconds);
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
/**
* Chat completion с single turn (system + user). Returns assistant message
* content (typically JSON-formatted для AI Schema Assist use case).
*
* @throws LlmException на timeout / network failure / non-2xx response
*/
public String chat(String systemPrompt, String userPrompt) throws LlmException {
if (endpoint == null || endpoint.isBlank()) {
throw new LlmException("ordinis.ai.endpoint не настроен");
}
String url = endpoint + "/v1/chat/completions";
ObjectNode body = mapper.createObjectNode();
body.put("model", model);
body.put("stream", false);
// Lower temperature для structured output (JSON) детерминизм важнее
// diversity. 0.2 standard для code/schema generation.
body.put("temperature", 0.2);
ArrayNode messages = body.putArray("messages");
messages.addObject().put("role", "system").put("content", systemPrompt);
messages.addObject().put("role", "user").put("content", userPrompt);
String payload;
try {
payload = mapper.writeValueAsString(body);
} catch (Exception e) {
throw new LlmException("serialization failed: " + e.getMessage());
}
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(URI.create(url))
.header("Content-Type", "application/json")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(payload));
if (bearerToken != null && !bearerToken.isBlank()) {
reqBuilder.header("Authorization", "Bearer " + bearerToken);
}
HttpResponse<String> response;
long start = System.currentTimeMillis();
try {
response = http.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString());
} catch (java.net.http.HttpTimeoutException e) {
throw new LlmException("LLM timeout > " + timeout.toSeconds() + "s");
} catch (Exception e) {
throw new LlmException("LLM connection failed: " + e.getClass().getSimpleName() + "" + e.getMessage());
}
long durationMs = System.currentTimeMillis() - start;
int status = response.statusCode();
if (status < 200 || status >= 300) {
log.warn("LLM HTTP {} {}ms: {}", status, durationMs, truncate(response.body(), 300));
throw new LlmException("LLM HTTP " + status);
}
String content;
try {
JsonNode root = mapper.readTree(response.body());
JsonNode choices = root.get("choices");
if (choices == null || !choices.isArray() || choices.isEmpty()) {
throw new LlmException("LLM response missing 'choices' array");
}
JsonNode msg = choices.get(0).get("message");
if (msg == null || msg.get("content") == null) {
throw new LlmException("LLM response missing message.content");
}
content = msg.get("content").asText();
} catch (LlmException le) {
throw le;
} catch (Exception e) {
throw new LlmException("LLM response parse failed: " + e.getMessage());
}
log.info("LLM ok model={} duration_ms={} response_chars={}", model, durationMs, content.length());
return content;
}
public List<String> describeConfig() {
return List.of(
"endpoint=" + endpoint,
"model=" + model,
"timeout=" + timeout.toSeconds() + "s",
"bearer=" + (bearerToken == null || bearerToken.isBlank() ? "no" : "yes"));
}
private static String truncate(String s, int max) {
if (s == null) return "";
return s.length() <= max ? s : s.substring(0, max) + "…[" + s.length() + "]";
}
/** Domain exception для всех LLM HTTP / parse failures. Caught controller-side. */
public static class LlmException extends Exception {
public LlmException(String message) { super(message); }
}
}
@@ -0,0 +1,130 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.restapi.service.ai.AiSchemaService;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* AI Schema Assist per-field suggest endpoint.
*
* <p>{@code POST /api/v1/ai/suggest-field}
* <pre>
* Request: { existingSchema: {...JSON Schema}, prompt: "Орбита — апогей..." }
* Success: { fieldName: "orbit", schema: {type:"object", properties:{...}} }
* Error: 422 {code, message} bad output / parse failure
* 503 {code:"circuit_open"} breaker active
* 429 {code:"rate_limit"} too many calls
* </pre>
*
* <p>Rate limit: 30 calls/minute per IP. Simple in-memory counter для
* single-writer setup достаточно (production usage будет десятков
* запросов в день).
*
* <p>Endpoint conditionally registered ({@code ConditionalOnBean(AiSchemaService.class)})
* если AI disabled (ordinis.ai.enabled=false) controller не создаётся,
* frontend получит 404 и спрячет AI button.
*/
@RestController
@RequestMapping("/api/v1/ai")
@ConditionalOnBean(AiSchemaService.class)
public class AiSchemaController {
private static final Logger log = LoggerFactory.getLogger(AiSchemaController.class);
private static final int RATE_LIMIT_PER_MIN = 30;
private static final long WINDOW_MS = 60_000;
private final AiSchemaService service;
private final ConcurrentHashMap<String, RateBucket> buckets = new ConcurrentHashMap<>();
public AiSchemaController(AiSchemaService service) {
this.service = service;
}
/**
* Lightweight probe endpoint frontend hits this чтобы decide показывать
* ли AI buttons. 200 = enabled (controller bean exists), 404 = disabled
* (bean missing through @ConditionalOnBean).
*/
@GetMapping("/info")
public AiInfoResponse info() {
return new AiInfoResponse(true);
}
public record AiInfoResponse(boolean enabled) {}
@PostMapping("/suggest-field")
public JsonNode suggestField(
@RequestBody SuggestFieldRequest req,
jakarta.servlet.http.HttpServletRequest http) {
String clientKey = http.getRemoteAddr() == null ? "anonymous" : http.getRemoteAddr();
if (!allowRequest(clientKey)) {
log.warn("AI rate limit hit для {}", clientKey);
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"AI suggest rate limit exceeded (30/min). Try again in a minute.");
}
if (req == null || req.prompt == null || req.prompt.isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "prompt is required");
}
try {
return service.suggestField(req.existingSchema, req.prompt);
} catch (AiSchemaService.AiSchemaException e) {
log.warn("AI suggest failed code={} msg={}", e.getCode(), e.getMessage());
HttpStatus status = switch (e.getCode()) {
case "circuit_open" -> HttpStatus.SERVICE_UNAVAILABLE;
case "bad_prompt", "bad_field_name", "bad_output" -> HttpStatus.UNPROCESSABLE_ENTITY;
case "llm_error" -> HttpStatus.BAD_GATEWAY;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
throw new ResponseStatusException(status, e.getMessage());
}
}
/** Simple per-IP rate limit (sliding window counter). */
private boolean allowRequest(String key) {
long now = System.currentTimeMillis();
RateBucket bucket = buckets.computeIfAbsent(key, k -> new RateBucket(now));
synchronized (bucket) {
if (now - bucket.windowStartedAt.get() > WINDOW_MS) {
bucket.windowStartedAt.set(now);
bucket.count.set(1);
return true;
}
return bucket.count.incrementAndGet() <= RATE_LIMIT_PER_MIN;
}
}
/** Body class — Jackson auto-binds. */
public static class SuggestFieldRequest {
public JsonNode existingSchema;
public String prompt;
}
private static class RateBucket {
final AtomicLong windowStartedAt;
final AtomicInteger count;
RateBucket(long now) {
this.windowStartedAt = new AtomicLong(now);
this.count = new AtomicInteger(0);
}
}
}