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

Approach C upgrade на templates foundation (!233). Admin может попросить AI
предложить новое поле — system prompt force'ит structural JSON Schema
fragment, admin review через accept/edit/reject. Bitemporal draft workflow
unchanged downstream.

## Backend
- `LlmAdapter` — OpenAI-compatible /v1/chat/completions client (Ollama,
  vLLM, OpenAI). Bean conditional на ordinis.ai.enabled=true.
- `AiSchemaService` — system prompt + 3 few-shot examples, JSON output
  parsing (с markdown fence stripping), validation (fieldName snake_case).
  In-memory circuit breaker: 10 fails в 60s → open 5 min.
- `AiSchemaController`:
  - GET /api/v1/ai/info → 200 (enabled) / 404 (disabled). Frontend probe.
  - POST /api/v1/ai/suggest-field → suggestion. Per-IP rate limit 30/min.
- All bean-conditional: ordinis.ai.enabled=false → controllers нет →
  endpoints 404 → frontend hides AI buttons automatically.

## Frontend
- `useAiFeatureAvailable` query — probes /ai/info, cache 5 min
- `useAiSuggestField` mutation — 30s timeout (LLM может быть slow)
- `AiFieldSuggestPanel` component — inline expandable: prompt → preview →
  accept/retry/reject. Error handling per HTTP status (503/429/422/502/404).
- DictionaryEditorDialog: AI toggle visible на schema tab когда
  aiAvailable=true. Accept wraps suggestion в mini-schema → parseSchemaJson
  (reuse existing kind-inference) → push в properties (overwrite если
  duplicate fieldName).
- i18n RU + EN

## Config
- application.yml: ordinis.ai.{enabled,endpoint,model,bearer-token,
  timeout-seconds}
- Helm writer.yaml: ORDINIS_AI_* env vars, optional secretRef для bearer
- values.yaml: ai.{enabled,endpoint,model,timeoutSeconds,bearerTokenSecretRef}
  defaults disabled

Pair с infra MR — values-staging enables AI с vortex.nstart.cloud Ollama
serving qwen2.5:7b.
This commit is contained in:
Zimin A.N.
2026-05-16 13:55:16 +03:00
parent db5d5c19e2
commit e4cd7b9709
10 changed files with 861 additions and 1 deletions
@@ -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);
}
}
}