diff --git a/test-env/README.md b/test-env/README.md index 72b4b466..97ba29d9 100644 --- a/test-env/README.md +++ b/test-env/README.md @@ -35,12 +35,18 @@ RabbitMQ. The template assistant is optional. To enable it, provide these variables before starting the stack: ```console -export VLA_MANAGER_AI_URL=https://your-provider.example/v1 +export VLA_MANAGER_AI_PROVIDER=gemini export VLA_MANAGER_AI_API_KEY=your-key -export VLA_MANAGER_AI_MODEL=your-model docker compose up -d --build ``` +Gemini uses Google's OpenAI-compatible endpoint and defaults to the low-cost +`gemini-3.1-flash-lite` model. For OpenAI-compatible services, set +`VLA_MANAGER_AI_PROVIDER=openai`, `VLA_MANAGER_AI_URL`, and `VLA_MANAGER_AI_MODEL`. +For Anthropic, set `VLA_MANAGER_AI_PROVIDER=anthropic`, `VLA_MANAGER_AI_URL`, and +`VLA_MANAGER_AI_MODEL`; its native Messages API is used automatically. `VLA_MANAGER_AI_URL` +may be a provider base URL or the complete endpoint URL. + The key is passed only to the VLA Manager API container. It is never included in the frontend bundle. Without these variables, the VLA Manager remains available and the assistant reports that it is not configured. To inspect a service while reviewing a failure: diff --git a/test-env/compose.yml b/test-env/compose.yml index 708cf674..510edaac 100755 --- a/test-env/compose.yml +++ b/test-env/compose.yml @@ -85,6 +85,7 @@ services: VLA_MANAGER_DB_URL: postgresql://postgres:postgres@postgres-vla-manager:5432/dva # Rendered requirements are validated by asking processing to compile them. VLA_MANAGER_API_PROCESSING_URL: http://dva-processing-vla-manager:5000 + VLA_MANAGER_AI_PROVIDER: ${VLA_MANAGER_AI_PROVIDER:-openai} VLA_MANAGER_AI_URL: ${VLA_MANAGER_AI_URL:-} VLA_MANAGER_AI_API_KEY: ${VLA_MANAGER_AI_API_KEY:-} VLA_MANAGER_AI_MODEL: ${VLA_MANAGER_AI_MODEL:-} diff --git a/vla-manager-api/README.md b/vla-manager-api/README.md index 8d578477..59e8d216 100644 --- a/vla-manager-api/README.md +++ b/vla-manager-api/README.md @@ -77,11 +77,32 @@ spec is still served and `/swagger/components.yaml` answers `404`. | `VLA_MANAGER_API_PORT` | `8000` | Listen port | | `VLA_MANAGER_API_LOG_LEVEL` | `info` | One of `critical`, `error`, `warning`, `info`, `debug` | | `VLA_MANAGER_API_PROCESSING_URL` | `http://localhost:5000` | URL to a DVA processing instance | -| `VLA_MANAGER_AI_URL` | *(empty)* | Base URL or full `/chat/completions` URL of an OpenAI-compatible assistant service. Empty disables the assistant. | +| `VLA_MANAGER_AI_PROVIDER` | `openai` | `openai` for OpenAI-compatible services, `gemini` for Gemini's compatibility endpoint, or `anthropic` for the native Messages API | +| `VLA_MANAGER_AI_URL` | *(empty)* | Provider base URL or complete endpoint URL. Defaults to the provider's public endpoint. Empty credentials disable the assistant. | | `VLA_MANAGER_AI_API_KEY` | *(empty)* | API key used only by the VLA Manager API. | -| `VLA_MANAGER_AI_MODEL` | *(empty)* | Model name sent to the assistant service. | +| `VLA_MANAGER_AI_MODEL` | *(empty)* | Model name sent to the assistant service. Gemini defaults to `gemini-3.1-flash-lite`; other providers require an explicit model. | | `VLA_MANAGER_AI_TIMEOUT_SECONDS` | `30` | Maximum assistant request duration. | The assistant returns a structured draft and never saves a template. The UI must show the draft for review and use the normal template validation and save actions afterward. Do not place the API key in frontend environment variables or browser code. + +Example provider settings: + +```console +# Gemini +VLA_MANAGER_AI_PROVIDER=gemini +VLA_MANAGER_AI_API_KEY=your-gemini-key + +# OpenAI or another OpenAI-compatible gateway +VLA_MANAGER_AI_PROVIDER=openai +VLA_MANAGER_AI_URL=https://api.openai.com/v1 +VLA_MANAGER_AI_API_KEY=your-openai-key +VLA_MANAGER_AI_MODEL=your-model + +# Anthropic +VLA_MANAGER_AI_PROVIDER=anthropic +VLA_MANAGER_AI_URL=https://api.anthropic.com/v1 +VLA_MANAGER_AI_API_KEY=your-anthropic-key +VLA_MANAGER_AI_MODEL=your-model +``` diff --git a/vla-manager-api/src/vla_manager_api/assistant.py b/vla-manager-api/src/vla_manager_api/assistant.py index 48c0c8c5..540732a3 100644 --- a/vla-manager-api/src/vla_manager_api/assistant.py +++ b/vla-manager-api/src/vla_manager_api/assistant.py @@ -11,6 +11,13 @@ from .config import cfg from .models import TemplateNew +_DEFAULT_URLS = { + "openai": "https://api.openai.com/v1", + "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/", + "anthropic": "https://api.anthropic.com/v1", +} +_DEFAULT_MODELS = {"gemini": "gemini-3.1-flash-lite"} + class AssistantUnavailable(RuntimeError): """The configured assistant service cannot be reached or is disabled.""" @@ -41,9 +48,25 @@ def build_assistant_messages( "VLA Manager template fields name, description, criterionType, " "targetAspect, and evaluationMethod. evaluationMethod must contain " "engine, variableSchema, and implementationTemplate. Supported " - "engines are SCHEMA, JQ, and GREAT_EXPECTATIONS. Never save data, " - "invent unsupported engines, or claim that a generated proposal is " - "validated. examples may contain passing and failing JSON samples. " + "engines are SCHEMA, JQ, and GREAT_EXPECTATIONS. criterionType must " + "be one of VALID_INVALID, IN_RANGE, GREATER_THAN, LESS_THAN. " + "targetAspect must be one of SYNTAX, TIMELINESS, ACCURACY, " + "COMPLETENESS, CONSISTENCY. Never save data, invent unsupported " + "engines, or claim that a generated proposal is validated. examples " + 'must be an object with "passing" and "failing" values. Each value ' + "should contain at least two representative examples when possible; " + "use an array for multiple examples, never a top-level array. Return " + "plain JSON without markdown fences. The implementationTemplate is " + "executed directly by the selected evaluator, so it must be code, " + "not an explanation. SCHEMA implementationTemplate must be a string " + "containing valid JSON Schema JSON, for example " + '\'{"type":"object","properties":{}}\'. ' + "JQ implementationTemplate must be the executable jq expression " + "that returns an object with a boolean success field and optional " + "details string. GREAT_EXPECTATIONS implementationTemplate must be " + "a string containing valid YAML expectation configuration with type, " + "kwargs, and optional meta fields. Never put prose such as 'check " + "that...' in implementationTemplate. " f"Available templates: {json.dumps(template_context)}. " f"Current draft: {json.dumps(current_template or {})}." ) @@ -53,32 +76,45 @@ def build_assistant_messages( return messages -def _completion_url() -> str: - base = cfg.ai_url.rstrip("/") +def _provider_config() -> tuple[str, str, str, str]: + provider = cfg.ai_provider + if provider not in _DEFAULT_URLS: + raise AssistantUnavailable( + "The configured assistant provider is not supported." + ) + + api_key = cfg.ai_api_key.strip() + base_url = cfg.ai_url.strip() or _DEFAULT_URLS[provider] + model = cfg.ai_model.strip() or _DEFAULT_MODELS.get(provider, "") + if not api_key or not model: + raise AssistantUnavailable("The template assistant is not configured.") + return provider, base_url, model, api_key + + +def _completion_url(base_url: str) -> str: + base = base_url.rstrip("/") if base.endswith("/chat/completions"): return base return f"{base}/chat/completions" -def _complete_sync(messages: list[dict[str, str]]) -> str: - if not cfg.ai_url or not cfg.ai_api_key or not cfg.ai_model: - raise AssistantUnavailable("The template assistant is not configured.") +def _messages_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/messages"): + return base + return f"{base}/messages" - body = json.dumps( - { - "model": cfg.ai_model, - "messages": messages, - "temperature": 0.2, - "response_format": {"type": "json_object"}, - } - ).encode() + +def _post_json( + url: str, + body: dict[str, Any], + headers: dict[str, str], +) -> dict[str, Any]: + headers = {**headers, "Content-Type": "application/json"} request = Request( - _completion_url(), - data=body, - headers={ - "Authorization": f"Bearer {cfg.ai_api_key}", - "Content-Type": "application/json", - }, + url, + data=json.dumps(body).encode(), + headers=headers, method="POST", ) try: @@ -88,6 +124,34 @@ def _complete_sync(messages: list[dict[str, str]]) -> str: raise AssistantUnavailable( "The template assistant could not be reached." ) from exc + except (json.JSONDecodeError, TypeError, UnicodeDecodeError) as exc: + raise AssistantResponseError("The assistant returned invalid JSON.") from exc + + if not isinstance(payload, dict): + raise AssistantResponseError("The assistant response had an unexpected shape.") + return payload + + +def _complete_openai_compatible( + messages: list[dict[str, str]], + base_url: str, + model: str, + api_key: str, + provider: str, +) -> str: + headers = {"Authorization": f"Bearer {api_key}"} + if provider == "gemini": + headers["x-goog-api-client"] = "prometheus-x-data-veracity/0.1" + payload = _post_json( + _completion_url(base_url), + { + "model": model, + "messages": messages, + "temperature": 0.2, + "response_format": {"type": "json_object"}, + }, + headers, + ) try: content = payload["choices"][0]["message"]["content"] @@ -100,6 +164,49 @@ def _complete_sync(messages: list[dict[str, str]]) -> str: return content +def _complete_anthropic( + messages: list[dict[str, str]], + base_url: str, + model: str, + api_key: str, +) -> str: + system_messages = [item["content"] for item in messages if item["role"] == "system"] + chat_messages = [item for item in messages if item["role"] in {"user", "assistant"}] + body: dict[str, Any] = { + "model": model, + "max_tokens": 2048, + "temperature": 0.2, + "messages": chat_messages, + } + if system_messages: + body["system"] = "\n\n".join(system_messages) + payload = _post_json( + _messages_url(base_url), + body, + {"x-api-key": api_key, "anthropic-version": "2023-06-01"}, + ) + blocks = payload.get("content") + if not isinstance(blocks, list): + raise AssistantResponseError("The assistant response had an unexpected shape.") + content = "".join( + block["text"] + for block in blocks + if isinstance(block, dict) + and block.get("type") == "text" + and isinstance(block.get("text"), str) + ) + if not content: + raise AssistantResponseError("The assistant response was not text.") + return content + + +def _complete_sync(messages: list[dict[str, str]]) -> str: + provider, base_url, model, api_key = _provider_config() + if provider == "anthropic": + return _complete_anthropic(messages, base_url, model, api_key) + return _complete_openai_compatible(messages, base_url, model, api_key, provider) + + async def complete_assistant(messages: list[dict[str, str]]) -> str: """Call the configured service without blocking the API event loop.""" return await asyncio.to_thread(_complete_sync, messages) @@ -117,6 +224,7 @@ def parse_assistant_response(content: str) -> dict[str, Any]: if proposal is not None and not isinstance(proposal, dict): raise AssistantResponseError("The assistant proposal is not an object.") if proposal is not None: + _validate_implementation_template(proposal) try: proposal = TemplateNew.model_validate(proposal).model_dump( by_alias=True, exclude_none=True @@ -129,3 +237,34 @@ def parse_assistant_response(content: str) -> dict[str, Any]: if examples is not None and not isinstance(examples, dict): raise AssistantResponseError("The assistant examples are not an object.") return {"message": response["message"], "proposal": proposal, "examples": examples} + + +def _validate_implementation_template(proposal: dict[str, Any]) -> None: + """Reject assistant prose before it reaches the template editor. + + Templates may contain Mustache placeholders, so static validation is only + applied when the implementation is self-contained. The selected evaluator + will validate rendered templates again when they are used. + """ + evaluation_method = proposal.get("evaluationMethod") + if not isinstance(evaluation_method, dict): + return + + engine = evaluation_method.get("engine") + implementation = evaluation_method.get("implementationTemplate") + if not isinstance(implementation, str) or not implementation.strip(): + return + if "{{" in implementation or "}}" in implementation: + return + + if engine == "SCHEMA": + try: + parsed = json.loads(implementation) + except json.JSONDecodeError as exc: + raise AssistantResponseError( + "The assistant returned a SCHEMA implementation that is not valid JSON Schema JSON." + ) from exc + if not isinstance(parsed, (dict, bool)): + raise AssistantResponseError( + "The assistant returned a SCHEMA implementation that is not a JSON Schema object." + ) diff --git a/vla-manager-api/src/vla_manager_api/config.py b/vla-manager-api/src/vla_manager_api/config.py index 6ff91c4d..5af53248 100644 --- a/vla-manager-api/src/vla_manager_api/config.py +++ b/vla-manager-api/src/vla_manager_api/config.py @@ -8,6 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict LogLevel = Literal["critical", "error", "warning", "info", "debug"] +AIProvider = Literal["openai", "gemini", "anthropic"] class Config(BaseSettings): @@ -41,8 +42,11 @@ class Config(BaseSettings): "/app/openapi.yaml", validation_alias="VLA_MANAGER_OPENAPI_FILE" ) - # Optional OpenAI-compatible chat completion service used by the - # template assistant. Empty URL disables the assistant cleanly. + # Optional chat completion service used by the template assistant. + # Empty credentials disable the assistant cleanly. + ai_provider: AIProvider = Field( + "openai", validation_alias="VLA_MANAGER_AI_PROVIDER" + ) ai_url: str = Field("", validation_alias="VLA_MANAGER_AI_URL") ai_api_key: str = Field("", validation_alias="VLA_MANAGER_AI_API_KEY") ai_model: str = Field("", validation_alias="VLA_MANAGER_AI_MODEL") diff --git a/vla-manager-api/tests/test_assistant_routes.py b/vla-manager-api/tests/test_assistant_routes.py index 388adcbb..1e74aeae 100644 --- a/vla-manager-api/tests/test_assistant_routes.py +++ b/vla-manager-api/tests/test_assistant_routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Iterator import pytest @@ -11,6 +12,20 @@ from vla_manager_api.vla_repo import FakeVLARepo +class FakeResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = json.dumps(payload).encode() + + def __enter__(self) -> FakeResponse: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return self._payload + + @pytest.fixture def fake_template_repo() -> FakeTemplateRepo: return FakeTemplateRepo() @@ -88,6 +103,39 @@ async def fake_complete(_messages: list[dict[str, str]]) -> str: assert response.json()["type"] == "ASSISTANT_INVALID_RESPONSE" +def test_assistant_rejects_prose_where_a_schema_implementation_is_required( + client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + async def fake_complete(_messages: list[dict[str, str]]) -> str: + return json.dumps( + { + "message": "Prepared a draft.", + "proposal": { + "name": "Production schema", + "description": "Checks production records.", + "criterionType": "VALID_INVALID", + "targetAspect": "SYNTAX", + "evaluationMethod": { + "engine": "SCHEMA", + "variableSchema": {"type": "object"}, + "implementationTemplate": "Check that production_kwh is present.", + }, + }, + } + ) + + monkeypatch.setattr( + "vla_manager_api.assistant_routes.complete_assistant", fake_complete + ) + + response = client.post( + "/assistant/template", json={"message": "Create a production schema."} + ) + + assert response.status_code == 502 + assert response.json()["type"] == "ASSISTANT_INVALID_RESPONSE" + + def test_assistant_reports_missing_model_configuration( client: TestClient, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -99,3 +147,103 @@ def test_assistant_reports_missing_model_configuration( assert response.status_code == 503 assert response.json()["type"] == "ASSISTANT_UNAVAILABLE" + + +def test_assistant_prompt_describes_the_template_enums_and_examples_shape() -> None: + from vla_manager_api.assistant import build_assistant_messages + + system_prompt = build_assistant_messages("draft", [], [], None)[0]["content"] + + assert ( + "criterionType must be one of VALID_INVALID, IN_RANGE, GREATER_THAN, LESS_THAN" + in system_prompt + ) + assert ( + "targetAspect must be one of SYNTAX, TIMELINESS, ACCURACY, COMPLETENESS, CONSISTENCY" + in system_prompt + ) + assert ( + 'examples must be an object with "passing" and "failing" values' + in system_prompt + ) + assert "at least two representative examples" in system_prompt + assert ( + "SCHEMA implementationTemplate must be a string containing valid JSON Schema" + in system_prompt + ) + assert ( + "JQ implementationTemplate must be the executable jq expression" + in system_prompt + ) + assert ( + "GREAT_EXPECTATIONS implementationTemplate must be a string containing valid YAML" + in system_prompt + ) + + +def test_gemini_uses_its_openai_compatible_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vla_manager_api import assistant + + monkeypatch.setattr(assistant.cfg, "ai_provider", "gemini") + monkeypatch.setattr(assistant.cfg, "ai_url", "") + monkeypatch.setattr(assistant.cfg, "ai_model", "") + monkeypatch.setattr(assistant.cfg, "ai_api_key", "gemini-test-key") + captured: dict[str, object] = {} + + def fake_urlopen(request: object, timeout: float) -> FakeResponse: + captured["request"] = request + captured["timeout"] = timeout + return FakeResponse({"choices": [{"message": {"content": '{"message":"ok"}'}}]}) + + monkeypatch.setattr(assistant, "urlopen", fake_urlopen) + + result = assistant._complete_sync([{"role": "user", "content": "hello"}]) + + request = captured["request"] + assert result == '{"message":"ok"}' + assert request.full_url == ( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ) + assert request.headers["Authorization"] == "Bearer gemini-test-key" + assert request.headers["X-goog-api-client"] == "prometheus-x-data-veracity/0.1" + body = json.loads(request.data) + assert body["model"] == "gemini-3.1-flash-lite" + + +def test_anthropic_uses_messages_api_headers_and_system_prompt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vla_manager_api import assistant + + monkeypatch.setattr(assistant.cfg, "ai_provider", "anthropic") + monkeypatch.setattr(assistant.cfg, "ai_url", "https://api.anthropic.com/v1") + monkeypatch.setattr(assistant.cfg, "ai_model", "claude-test") + monkeypatch.setattr(assistant.cfg, "ai_api_key", "anthropic-test-key") + captured: dict[str, object] = {} + + def fake_urlopen(request: object, timeout: float) -> FakeResponse: + captured["request"] = request + captured["timeout"] = timeout + return FakeResponse({"content": [{"type": "text", "text": '{"message":"ok"}'}]}) + + monkeypatch.setattr(assistant, "urlopen", fake_urlopen) + + result = assistant._complete_sync( + [ + {"role": "system", "content": "Return JSON only."}, + {"role": "user", "content": "hello"}, + ] + ) + + request = captured["request"] + assert result == '{"message":"ok"}' + assert request.full_url == "https://api.anthropic.com/v1/messages" + assert request.headers["X-api-key"] == "anthropic-test-key" + assert request.headers["Anthropic-version"] == "2023-06-01" + assert "Authorization" not in request.headers + body = json.loads(request.data) + assert body["model"] == "claude-test" + assert body["system"] == "Return JSON only." + assert body["messages"] == [{"role": "user", "content": "hello"}] diff --git a/vla-manager/src/api/assistantPresentation.js b/vla-manager/src/api/assistantPresentation.js new file mode 100644 index 00000000..ca32728e --- /dev/null +++ b/vla-manager/src/api/assistantPresentation.js @@ -0,0 +1,86 @@ +export function normaliseAssistantExamples (examples) { + const source = examples && typeof examples === 'object' && !Array.isArray(examples) + ? examples + : {} + + return { + passing: toList(source.passing), + failing: toList(source.failing) + } +} + +export function formatAssistantJson (value) { + if (typeof value === 'string') return value + + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +export function tokeniseAssistantJson (value) { + const text = String(formatAssistantJson(value) ?? '') + const tokens = [] + let index = 0 + + while (index < text.length) { + const character = text[index] + + if (character === '"') { + const start = index + index += 1 + while (index < text.length) { + if (text[index] === '\\') { + index += 2 + } else if (text[index] === '"') { + index += 1 + break + } else { + index += 1 + } + } + const isKey = /^\s*:/.test(text.slice(index)) + tokens.push({ text: text.slice(start, index), type: isKey ? 'key' : 'string' }) + continue + } + + if (/[-0-9]/.test(character)) { + const match = text.slice(index).match(/^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/) + if (match) { + tokens.push({ text: match[0], type: 'number' }) + index += match[0].length + continue + } + } + + const literal = text.slice(index).match(/^(true|false|null)\b/) + if (literal) { + tokens.push({ text: literal[0], type: literal[1] === 'null' ? 'null' : 'boolean' }) + index += literal[0].length + continue + } + + if ('{}[],:'.includes(character)) { + tokens.push({ text: character, type: 'punctuation' }) + index += 1 + continue + } + + const start = index + while (index < text.length && !/["{}\[\],:\s]/.test(text[index])) index += 1 + if (start === index) index += 1 + tokens.push({ text: text.slice(start, index), type: 'plain' }) + } + + return tokens +} + +export function assistantTextChunks (value) { + return String(value ?? '').match(/\s+|\S+\s*/g) || [] +} + +function toList (value) { + if (value === undefined || value === null) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/vla-manager/src/api/templateVariables.js b/vla-manager/src/api/templateVariables.js new file mode 100644 index 00000000..496520f4 --- /dev/null +++ b/vla-manager/src/api/templateVariables.js @@ -0,0 +1,55 @@ +export function renameTemplateVariable (schema, oldName, nextName) { + const name = nextName.trim() + const properties = schema.properties || {} + if (!name || name === oldName || properties[oldName] === undefined || properties[name] !== undefined) return schema + + const nextProperties = { ...properties, [name]: properties[oldName] } + delete nextProperties[oldName] + return { + ...schema, + properties: nextProperties, + required: (schema.required || []).map(item => item === oldName ? name : item) + } +} + +export function updateTemplateVariable (schema, name, patch) { + return { + ...schema, + properties: { + ...(schema.properties || {}), + [name]: { ...((schema.properties || {})[name] || {}), ...patch } + } + } +} + +export function removeTemplateVariable (schema, name) { + const properties = { ...(schema.properties || {}) } + delete properties[name] + return { + ...schema, + properties, + required: (schema.required || []).filter(item => item !== name) + } +} + +export function createVariableKeyStore () { + const keys = new Map() + let sequence = 0 + + return { + keyFor (name) { + if (!keys.has(name)) keys.set(name, `variable-${++sequence}`) + return keys.get(name) + }, + rename (oldName, nextName) { + const key = keys.get(oldName) + if (key) { + keys.delete(oldName) + keys.set(nextName, key) + } + }, + remove (name) { + keys.delete(name) + } + } +} diff --git a/vla-manager/src/components/AssistantJson.vue b/vla-manager/src/components/AssistantJson.vue new file mode 100644 index 00000000..8cbd2a8d --- /dev/null +++ b/vla-manager/src/components/AssistantJson.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/vla-manager/src/components/TemplateAssistant.vue b/vla-manager/src/components/TemplateAssistant.vue index 783563ad..27429223 100644 --- a/vla-manager/src/components/TemplateAssistant.vue +++ b/vla-manager/src/components/TemplateAssistant.vue @@ -1,59 +1,178 @@ diff --git a/vla-manager/src/components/TemplateEditor.vue b/vla-manager/src/components/TemplateEditor.vue index ba0d3409..4c8315f0 100644 --- a/vla-manager/src/components/TemplateEditor.vue +++ b/vla-manager/src/components/TemplateEditor.vue @@ -30,7 +30,7 @@
Template variablesAdd only the values a user must provide when this template is used.
Add variable
-
+
@@ -64,6 +64,7 @@ import TemplatePreview from './TemplatePreview.vue' import TemplateAssistant from './TemplateAssistant.vue' import { applyTemplateProposal } from '../api/assistant.js' import { createTemplate, renderTemplate, updateTemplate } from '../api/templates.js' +import { createVariableKeyStore, removeTemplateVariable, renameTemplateVariable, updateTemplateVariable } from '../api/templateVariables.js' const props = defineProps({ template: { type: Object, default: null } }) const emit = defineEmits(['saved', 'cancel']) @@ -86,7 +87,8 @@ function blankTemplate () { function clone (value) { return JSON.parse(JSON.stringify(value)) } const form = ref(blankTemplate()) const isEditing = computed(() => Boolean(props.template?.id)) -const variableRows = computed(() => Object.entries(form.value.evaluationMethod.variableSchema.properties || {}).map(([name, definition]) => ({ name, definition: definition || {} }))) +const variableKeys = createVariableKeyStore() +const variableRows = computed(() => Object.entries(form.value.evaluationMethod.variableSchema.properties || {}).map(([name, definition]) => ({ name, key: variableKeys.keyFor(name), definition: definition || {} }))) const canRender = computed(() => Boolean(form.value.id && Object.keys(errors.value).length === 0)) watch(() => props.template, value => { form.value = value ? clone(value) : blankTemplate(); previewValue.value = '' }, { immediate: true }) @@ -104,25 +106,20 @@ function addVariable () { updateVariable(name, { type: 'string', description: '' }, true) } function updateVariable (name, patch, create = false) { - const properties = { ...form.value.evaluationMethod.variableSchema.properties } - properties[name] = { ...(properties[name] || {}), ...patch } - form.value.evaluationMethod.variableSchema.properties = properties + form.value.evaluationMethod.variableSchema = updateTemplateVariable(form.value.evaluationMethod.variableSchema, name, patch) if (create) toggleRequired(name, true) } function renameVariable (oldName, nextName) { const name = nextName.trim() - if (!name || name === oldName || form.value.evaluationMethod.variableSchema.properties[name]) return - const properties = { ...form.value.evaluationMethod.variableSchema.properties } - properties[name] = properties[oldName] - delete properties[oldName] - form.value.evaluationMethod.variableSchema.properties = properties - form.value.evaluationMethod.variableSchema.required = (form.value.evaluationMethod.variableSchema.required || []).map(item => item === oldName ? name : item) + const schema = form.value.evaluationMethod.variableSchema + const nextSchema = renameTemplateVariable(schema, oldName, name) + if (nextSchema === schema) return + variableKeys.rename(oldName, name) + form.value.evaluationMethod.variableSchema = nextSchema } function removeVariable (name) { - const properties = { ...form.value.evaluationMethod.variableSchema.properties } - delete properties[name] - form.value.evaluationMethod.variableSchema.properties = properties - toggleRequired(name, false) + variableKeys.remove(name) + form.value.evaluationMethod.variableSchema = removeTemplateVariable(form.value.evaluationMethod.variableSchema, name) } function toggleRequired (name, checked) { const current = new Set(form.value.evaluationMethod.variableSchema.required || []) diff --git a/vla-manager/test/template-assistant.test.mjs b/vla-manager/test/template-assistant.test.mjs index 9ab8c85a..808c3433 100644 --- a/vla-manager/test/template-assistant.test.mjs +++ b/vla-manager/test/template-assistant.test.mjs @@ -2,6 +2,82 @@ import test from 'node:test' import assert from 'node:assert/strict' import { applyTemplateProposal, assistantErrorMessage } from '../src/api/assistant.js' +import { + assistantTextChunks, + formatAssistantJson, + normaliseAssistantExamples, + tokeniseAssistantJson +} from '../src/api/assistantPresentation.js' +import { createVariableKeyStore, renameTemplateVariable } from '../src/api/templateVariables.js' + +test('renames one template variable without disturbing siblings or focus identity', () => { + const schema = { + type: 'object', + properties: { + first: { type: 'string', description: 'First value' }, + second: { type: 'number', description: 'Second value' } + }, + required: ['first', 'second'] + } + const keys = createVariableKeyStore() + const firstKey = keys.keyFor('first') + const secondKey = keys.keyFor('second') + + const next = renameTemplateVariable(schema, 'first', 'firs') + keys.rename('first', 'firs') + + assert.deepEqual(next.properties, { + firs: { type: 'string', description: 'First value' }, + second: { type: 'number', description: 'Second value' } + }) + assert.deepEqual(next.required, ['firs', 'second']) + assert.deepEqual(schema.properties, { + first: { type: 'string', description: 'First value' }, + second: { type: 'number', description: 'Second value' } + }) + assert.equal(keys.keyFor('firs'), firstKey) + assert.equal(keys.keyFor('second'), secondKey) +}) + +test('normalises assistant examples into passing and failing lists', () => { + assert.deepEqual( + normaliseAssistantExamples({ + passing: { timestamp: '2026-09-08T08:00:00Z', production_kwh: 420 }, + failing: [ + { timestamp: 'not-a-date' }, + { timestamp: '2026-09-08T08:00:00Z', production_kwh: -2 } + ] + }), + { + passing: [{ timestamp: '2026-09-08T08:00:00Z', production_kwh: 420 }], + failing: [ + { timestamp: 'not-a-date' }, + { timestamp: '2026-09-08T08:00:00Z', production_kwh: -2 } + ] + } + ) +}) + +test('formats assistant JSON without losing primitive values', () => { + assert.equal(formatAssistantJson({ valid: true, count: 2 }), '{\n "valid": true,\n "count": 2\n}') + assert.equal(formatAssistantJson('not available'), 'not available') +}) + +test('tokenises JSON for readable syntax colouring', () => { + const tokens = tokeniseAssistantJson({ valid: true, count: 2, label: 'ok' }) + + assert.ok(tokens.some(token => token.type === 'key' && token.text === '"valid"')) + assert.ok(tokens.some(token => token.type === 'boolean' && token.text === 'true')) + assert.ok(tokens.some(token => token.type === 'number' && token.text === '2')) + assert.ok(tokens.some(token => token.type === 'string' && token.text === '"ok"')) +}) + +test('splits assistant text into whitespace-preserving word chunks', () => { + assert.deepEqual( + assistantTextChunks('Template ready. Review the draft.'), + ['Template ', 'ready. ', 'Review ', 'the ', 'draft.'] + ) +}) test('applies only template fields from an assistant proposal', () => { const current = {