Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/spec/vla-manager-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,51 @@ tags:
description: Endpoints related to the management of VLA templates.
- name: Dev
description: Development-only bulk deletion endpoints.
- name: Assistant
description: Endpoints for the optional VLA template design assistant.


paths:
/assistant/template:
post:
tags: [Assistant]
summary: Generate a VLA template draft
description: >-
Sends a natural-language template request to the configured assistant
service and returns a structured draft. The draft is never persisted
by this endpoint.
operationId: assistTemplate
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AssistantRequest'
responses:
'200':
description: Assistant response containing an optional template draft.
content:
application/json:
schema:
$ref: '#/components/schemas/AssistantResponse'
'422':
description: Empty or invalid assistant request.
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
'502':
description: Assistant returned an invalid response.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
description: Assistant service is not configured or unavailable.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/template:
get:
tags: [Templates]
Expand Down Expand Up @@ -583,6 +625,47 @@ components:
implementation:
type: string
description: The rendered data-quality fragment.
AssistantRequest:
type: object
required: [message]
additionalProperties: false
properties:
message:
type: string
minLength: 1
maxLength: 4000
description: Natural-language description of the desired requirement.
conversation:
type: array
maxItems: 20
items:
type: object
required: [role, content]
additionalProperties: false
properties:
role:
type: string
enum: [user, assistant]
content:
type: string
currentTemplate:
type: object
additionalProperties: true
AssistantResponse:
type: object
required: [message]
additionalProperties: false
properties:
message:
type: string
proposal:
allOf:
- $ref: '#/components/schemas/TemplateCreate'
nullable: true
examples:
type: object
nullable: true
additionalProperties: true
QualityEngine:
type: string
enum: [SCHEMA, GREAT_EXPECTATIONS, JQ]
Expand Down
11 changes: 11 additions & 0 deletions test-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ The provider and consumer dashboards use their corresponding DVA APIs. Both dash

The environment follows the refactored HTTP-only architecture. It does not start RabbitMQ or ACA-Py.

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_API_KEY=your-key
export VLA_MANAGER_AI_MODEL=your-model
docker compose up -d --build
```

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:

```console
Expand Down
4 changes: 4 additions & 0 deletions test-env/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ services:
service: vla-manager-api
environment:
VLA_MANAGER_DB_URL: postgresql://postgres:postgres@postgres-vla-manager:5432/dva
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:-}
VLA_MANAGER_AI_TIMEOUT_SECONDS: ${VLA_MANAGER_AI_TIMEOUT_SECONDS:-30}
ports: [8000:8000]
healthcheck: &api-health
test: nc -z localhost 8000
Expand Down
50 changes: 50 additions & 0 deletions test/template-assistant.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'

import { applyTemplateProposal, assistantErrorMessage } from '../vla-manager/src/api/assistant.js'

test('applies only template fields from an assistant proposal', () => {
const current = {
id: 'template-1',
name: 'Existing template',
description: 'Keep this draft safe',
criterionType: 'VALID_INVALID',
targetAspect: 'SYNTAX',
evaluationMethod: {
engine: 'JQ',
variableSchema: { type: 'object', properties: {}, required: [] },
implementationTemplate: '.ok'
}
}

const result = applyTemplateProposal(current, {
name: 'xAPI schema',
description: 'Checks xAPI data.',
criterionType: 'VALID_INVALID',
targetAspect: 'SYNTAX',
evaluationMethod: {
engine: 'SCHEMA',
variableSchema: { type: 'object', properties: {}, required: [] },
implementationTemplate: '{"type":"object"}'
},
id: 'must-not-replace'
})

assert.equal(result.id, 'template-1')
assert.equal(result.name, 'xAPI schema')
assert.equal(result.evaluationMethod.engine, 'SCHEMA')
})

test('does not replace a template when the assistant has no proposal', () => {
const current = { name: 'Draft', evaluationMethod: { engine: 'JQ' } }

assert.deepEqual(applyTemplateProposal(current, null), current)
})

test('uses RFC problem detail fields for assistant errors', () => {
assert.equal(
assistantErrorMessage({ response: { data: { detail: 'Model is unavailable.' } } }),
'Model is unavailable.'
)
assert.equal(assistantErrorMessage({ message: 'Network failed' }), 'Network failed')
})
13 changes: 11 additions & 2 deletions vla-manager-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ routes, and nothing calls this service yet.
| `DELETE /template/{id}` | VLA Manager UI | Delete one template |
| `DELETE /template` | Admin only | Wipe all templates |
| `POST /template/{id}/render` | VLA Manager UI | Render a template's `implementationTemplate` with a model |
| `POST /assistant/template` | VLA Manager UI | Generate an unsaved template draft from a natural-language request |

This service intentionally does **not** do evaluation, attestation, or credential issuance —
those are concerns of `dva-processing` and the `dva-vc-manager` respectively.
Expand All @@ -57,8 +58,8 @@ runs without a Postgres; state is lost on restart, so set the DSN for any deploy

## Run in docker-compose

Not yet wired into `test-env/compose.yml`. The `Dockerfile` builds and runs standalone,
and expects the spec mounted at `/app/openapi.yaml` (see `VLA_MANAGER_OPENAPI_FILE`).
The service is wired into `test-env/compose.yml`. The `Dockerfile` expects the spec at
`/app/openapi.yaml` (see `VLA_MANAGER_OPENAPI_FILE`).

## Configuration (.env)

Expand All @@ -69,3 +70,11 @@ and expects the spec mounted at `/app/openapi.yaml` (see `VLA_MANAGER_OPENAPI_FI
| `VLA_MANAGER_API_HOST` | `0.0.0.0` | Listen address |
| `VLA_MANAGER_API_PORT` | `8000` | Listen port |
| `VLA_MANAGER_API_LOG_LEVEL` | `info` | One of `critical`, `error`, `warning`, `info`, `debug` |
| `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_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_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.
131 changes: 131 additions & 0 deletions vla-manager-api/src/vla_manager_api/assistant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Small client and prompt builder for the VLA template assistant."""

from __future__ import annotations

import asyncio
import json
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

from .config import cfg
from .models import TemplateNew


class AssistantUnavailable(RuntimeError):
"""The configured assistant service cannot be reached or is disabled."""


class AssistantResponseError(ValueError):
"""The assistant returned a response that is not valid JSON."""


def build_assistant_messages(
message: str,
templates: list[dict[str, Any]],
conversation: list[dict[str, str]],
current_template: dict[str, Any] | None,
) -> list[dict[str, str]]:
"""Build the bounded context sent to the configured chat service."""
template_context = [
{
"name": item.get("name"),
"description": item.get("description"),
"engine": item.get("evaluationMethod", {}).get("engine"),
}
for item in templates
]
system = (
"You are the VLA template design assistant. Return JSON only with "
"the shape {message, proposal, examples}. proposal must use the "
"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. "
f"Available templates: {json.dumps(template_context)}. "
f"Current draft: {json.dumps(current_template or {})}."
)
messages = [{"role": "system", "content": system}]
messages.extend(conversation[-10:])
messages.append({"role": "user", "content": message.strip()})
return messages


def _completion_url() -> str:
base = cfg.ai_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.")

body = json.dumps(
{
"model": cfg.ai_model,
"messages": messages,
"temperature": 0.2,
"response_format": {"type": "json_object"},
}
).encode()
request = Request(
_completion_url(),
data=body,
headers={
"Authorization": f"Bearer {cfg.ai_api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=cfg.ai_timeout_seconds) as response:
payload = json.loads(response.read())
except (HTTPError, URLError, TimeoutError, OSError) as exc:
raise AssistantUnavailable(
"The template assistant could not be reached."
) from exc
Comment on lines +84 to +90

try:
content = payload["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise AssistantResponseError(
"The assistant response had an unexpected shape."
) from exc
if not isinstance(content, str):
raise AssistantResponseError("The assistant response was not text.")
return content


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)


def parse_assistant_response(content: str) -> dict[str, Any]:
"""Parse model JSON and require the top-level fields used by the UI."""
try:
response = json.loads(content)
except json.JSONDecodeError as exc:
raise AssistantResponseError("The assistant returned invalid JSON.") from exc
if not isinstance(response, dict) or not isinstance(response.get("message"), str):
raise AssistantResponseError("The assistant response is missing its message.")
proposal = response.get("proposal")
if proposal is not None and not isinstance(proposal, dict):
raise AssistantResponseError("The assistant proposal is not an object.")
if proposal is not None:
try:
proposal = TemplateNew.model_validate(proposal).model_dump(
by_alias=True, exclude_none=True
)
except ValueError as exc:
raise AssistantResponseError(
"The assistant proposal does not match the template schema."
) from exc
examples = response.get("examples")
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}
Loading
Loading