mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 03:14:12 +00:00
new feature: JEV Integration. Now there are a current (native) LLM decision model and a JEV decision model
This commit is contained in:
Binary file not shown.
BIN
tests/__pycache__/test_jev_endpoint.cpython-313.pyc
Normal file
BIN
tests/__pycache__/test_jev_endpoint.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
28
tests/test_jev_choice_fix.py
Normal file
28
tests/test_jev_choice_fix.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import pytest
|
||||
from agent_framework.decisions.adapters.typesafe import build_request, normalize_answers, TypeSafeContractError
|
||||
from agent_framework.decisions import client
|
||||
|
||||
|
||||
def test_choice_wire_uses_verified_noul():
|
||||
questions = [client.choice('decision', ['CONTINUE','ROUTE','END_SESSION'])]
|
||||
body = build_request('state', questions)
|
||||
assert len(body['questions']) == 3
|
||||
assert all(x['type'] == 'noul' for x in body['questions'].values())
|
||||
answer = {'answers': {'__jev_q0_o0': {'noul': .12}, '__jev_q0_o1': {'noul': .94}, '__jev_q0_o2': {'noul': .07}}}
|
||||
assert normalize_answers(answer, questions) == [{'name': 'decision', 'type': 'choice', 'choice': 'ROUTE', 'confidence': .94}]
|
||||
|
||||
|
||||
def test_ambiguous_choice_fails_closed():
|
||||
questions = [client.choice('decision', ['CONTINUE','ROUTE'])]
|
||||
with pytest.raises(TypeSafeContractError, match='AMBIGUOUS_DIRECT_CHOICE'):
|
||||
normalize_answers({'answers': {'__jev_q0_o0': {'noul': .81}, '__jev_q0_o1': {'noul': .78}}}, questions)
|
||||
|
||||
|
||||
def test_predicate_wire():
|
||||
questions = [client.predicate('allowed')]
|
||||
assert normalize_answers({'answers': {'__jev_q0': {'noul': .9}}}, questions)[0]['probability'] == .9
|
||||
|
||||
|
||||
def test_score_fails_closed():
|
||||
with pytest.raises(TypeSafeContractError, match='UNSUPPORTED_DIRECT_SCORE'):
|
||||
build_request('state', [client.score('score')])
|
||||
133
tests/test_jev_decisions.py
Normal file
133
tests/test_jev_decisions.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Offline JEV transport/contract tests; no live API key or network required."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework.decisions import client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_environment(monkeypatch):
|
||||
import os
|
||||
for key in list(os.environ):
|
||||
if key.startswith(('DECISION_', 'JEV_')) or key == 'AI_GATEWAY_API_KEY':
|
||||
monkeypatch.delenv(key)
|
||||
|
||||
|
||||
def answer(questions, *, confidence=.95):
|
||||
return {'model': 'typesafe-ai/jev', 'answers': [
|
||||
{'name': q['name'], 'type': q['type'], **(
|
||||
{'choice': q['choices'][0]['value'], 'confidence': confidence} if q['type'] == 'choice' else
|
||||
{'probability': confidence} if q['type'] == 'predicate' else
|
||||
{'score': 8.0, 'confidence': confidence})} for q in questions]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_preserves_current_call_and_kwargs(monkeypatch):
|
||||
current = AsyncMock(return_value='legacy response')
|
||||
monkeypatch.setattr(client, '_post', lambda body: pytest.fail('JEV must not run'))
|
||||
result = await client.invoke(type('LLM', (), {'ainvoke': current})(), [{'content': 'x'}],
|
||||
decision_point='router', decision_questions=[], profile_name='router', temperature=0)
|
||||
assert result == 'legacy response'
|
||||
current.assert_awaited_once_with([{'content': 'x'}], profile_name='router', temperature=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_individual_selection_and_override(monkeypatch):
|
||||
monkeypatch.setenv('DECISION_PROVIDER', 'jev')
|
||||
monkeypatch.setenv('DECISION_ROUTER_PROVIDER', 'current')
|
||||
assert not client.selected('router')
|
||||
assert client.selected('route_continuity')
|
||||
monkeypatch.setenv('DECISION_PROVIDER', 'current')
|
||||
monkeypatch.setenv('DECISION_ROUTE_CONTINUITY_PROVIDER', 'jev')
|
||||
assert client.selected('route_continuity')
|
||||
assert not client.selected('router')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_contract_and_shared_request(monkeypatch):
|
||||
monkeypatch.setenv('DECISION_ROUTER_PROVIDER', 'jev')
|
||||
requests = []
|
||||
def transport(body):
|
||||
requests.append(body)
|
||||
return answer(body['questions'])
|
||||
monkeypatch.setattr(client, '_post', transport)
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock()})()
|
||||
result = json.loads(await client.invoke(llm, [{'content': 'context'}], decision_point='router',
|
||||
decision_questions=[client.choice('intent', ['billing', 'orders']), client.predicate('allowed'), client.score('score')]))
|
||||
assert result['intent'] == 'billing' and result['allowed'] is True and result['score'] == 8
|
||||
assert len(requests) == 1 and requests[0]['model'] == 'typesafe-ai/jev'
|
||||
assert 'context' in requests[0]['input']
|
||||
llm.ainvoke.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['network', 'unknown_choice', 'missing', 'duplicate', 'nan', 'low', 'no_confidence', 'wrong_type'])
|
||||
async def test_invalid_or_uncertain_jev_falls_back_once(monkeypatch, failure):
|
||||
monkeypatch.setenv('DECISION_ROUTER_PROVIDER', 'jev')
|
||||
def transport(body):
|
||||
if failure == 'network': raise TimeoutError()
|
||||
response = answer(body['questions'])
|
||||
a = response['answers'][0]
|
||||
if failure == 'unknown_choice': a['choice'] = 'undeclared'
|
||||
if failure == 'missing': response['answers'] = []
|
||||
if failure == 'duplicate': response['answers'] *= 2
|
||||
if failure == 'nan': a['confidence'] = float('nan')
|
||||
if failure == 'low': a['confidence'] = .4
|
||||
if failure == 'no_confidence': a.pop('confidence')
|
||||
if failure == 'wrong_type': a['type'] = 'predicate'
|
||||
return response
|
||||
monkeypatch.setattr(client, '_post', transport)
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock(return_value='legacy')})()
|
||||
assert await client.invoke(llm, [], decision_point='router', decision_questions=[client.choice('intent', ['a'])]) == 'legacy'
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_fallback_surfaces_failure(monkeypatch):
|
||||
monkeypatch.setenv('DECISION_ROUTER_PROVIDER', 'jev')
|
||||
monkeypatch.setenv('DECISION_FALLBACK_TO_CURRENT', 'false')
|
||||
monkeypatch.setattr(client, '_post', lambda body: {'answers': []})
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock()})()
|
||||
with pytest.raises(ValueError):
|
||||
await client.invoke(llm, [], decision_point='router', decision_questions=[client.predicate('allowed')])
|
||||
llm.ainvoke.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generative_bypass_even_with_global_jev(monkeypatch):
|
||||
monkeypatch.setenv('DECISION_PROVIDER', 'jev')
|
||||
monkeypatch.setattr(client, '_post', lambda body: pytest.fail('rewrite must use current'))
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock(return_value='rewritten')})()
|
||||
assert await client.invoke(llm, [], decision_point='rewrite', decision_questions=[], decision_generative=True) == 'rewritten'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_intent_preserves_order_literal_fragments(monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
monkeypatch.setenv('DECISION_MULTI_INTENT_PROVIDER', 'jev')
|
||||
candidates = {'operations': [{'source_text': 'pedido', 'intent': 'x'}, {'source_text': 'fatura', 'intent': 'y'}]}
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock(return_value=json.dumps(candidates))})()
|
||||
monkeypatch.setattr(client, '_post', lambda body: answer(body['questions']))
|
||||
result = json.loads(await client.multi_intent(llm, [], intents=[SimpleNamespace(name='billing')], text='pedido e fatura'))
|
||||
assert [o['source_text'] for o in result['operations']] == ['pedido', 'fatura']
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_semantic_classifier_and_jev_are_independent(monkeypatch, tmp_path):
|
||||
from types import SimpleNamespace
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
config = tmp_path / 'routing.yaml'
|
||||
config.write_text('router: {fallback_agent: fallback}\nintents: []\n')
|
||||
llm = type('LLM', (), {'ainvoke': AsyncMock(return_value='NO')})()
|
||||
router = EnterpriseRouter(SimpleNamespace(ROUTING_CONFIG_PATH=str(config), ENABLE_LLM_ROUTER=False, ENABLE_ROUTE_STICKINESS=False), llm=llm)
|
||||
expected = {'allowed_values': ['YES', 'NO'], 'semantic_classifier': {'enabled': True, 'prompt': 'Use meaning: {{ user_input }}'}}
|
||||
monkeypatch.setenv('DECISION_WORKFLOW_EXPECTED_INPUT_PROVIDER', 'jev')
|
||||
monkeypatch.setattr(client, '_post', lambda body: answer(body['questions']))
|
||||
value, _ = await router._classify_expected_input_semantically(text='yes', expected_input=expected, pause_prompt='Confirm?')
|
||||
assert value == 'YES'
|
||||
value, _ = await router._classify_expected_input_semantically(text='no', expected_input=expected, pause_prompt='Confirm?', component_name='transaction.confirmation')
|
||||
assert value == 'NO'
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
33
tests/test_jev_endpoint.py
Normal file
33
tests/test_jev_endpoint.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Offline regression tests: python -m unittest discover -s tests -p test_jev_endpoint.py"""
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
CLIENT = Path(__file__).resolve().parents[1] / 'libs/agent_framework/src/agent_framework/decisions/client.py'
|
||||
spec = importlib.util.spec_from_file_location('jev_client_patch', CLIENT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
class EndpointTests(unittest.TestCase):
|
||||
def test_default(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(module._jev_endpoint(), 'https://ai-gateway.vercel.sh/v1/decisions')
|
||||
|
||||
def test_typesafe_endpoint(self):
|
||||
with patch.dict(os.environ, {'JEV_ENDPOINT': 'https://api.typesafe.ai/v1/systemone'}, clear=True):
|
||||
self.assertEqual(module._jev_endpoint(), 'https://api.typesafe.ai/v1/systemone')
|
||||
|
||||
def test_reject_untrusted_host(self):
|
||||
with patch.dict(os.environ, {'JEV_ENDPOINT': 'https://evil.example/v1/decisions'}, clear=True):
|
||||
with self.assertRaises(ValueError):
|
||||
module._jev_endpoint()
|
||||
|
||||
def test_reject_http(self):
|
||||
with patch.dict(os.environ, {'JEV_ENDPOINT': 'http://api.typesafe.ai/v1/systemone'}, clear=True):
|
||||
with self.assertRaises(ValueError):
|
||||
module._jev_endpoint()
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
210
tests/test_jev_transport_standalone.py
Normal file
210
tests/test_jev_transport_standalone.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Run without project dependencies: python3 tests/test_jev_transport_standalone.py."""
|
||||
import ast
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE = ROOT / 'libs/agent_framework/src/agent_framework/decisions/client.py'
|
||||
spec = importlib.util.spec_from_file_location('jev_transport', MODULE)
|
||||
client = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(client)
|
||||
|
||||
|
||||
def response(questions, confidence=.95):
|
||||
return {'model': 'typesafe-ai/jev', 'answers': [
|
||||
{'type': q['type'], 'name': q['name'], **(
|
||||
{'choice': q['choices'][0]['value'], 'confidence': confidence} if q['type'] == 'choice' else
|
||||
{'probability': confidence} if q['type'] == 'predicate' else
|
||||
{'score': 8, 'confidence': confidence})} for q in questions]}
|
||||
|
||||
|
||||
class DecisionTransportTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.env = patch.dict(os.environ, {k: v for k, v in os.environ.items() if not k.startswith(('DECISION_', 'JEV_'))}, clear=True)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
self.llm = type('LLM', (), {'ainvoke': AsyncMock(return_value='legacy')})()
|
||||
|
||||
async def call(self, **kwargs):
|
||||
return await client.invoke(self.llm, [{'content': 'test context'}], decision_point='router',
|
||||
decision_questions=[client.choice('intent', ['billing', 'orders'])], **kwargs)
|
||||
|
||||
async def test_default_passes_original_kwargs_without_network(self):
|
||||
with patch.object(client, '_post', side_effect=AssertionError('network')):
|
||||
self.assertEqual(await self.call(profile_name='router'), 'legacy')
|
||||
self.llm.ainvoke.assert_awaited_once_with([{'content': 'test context'}], profile_name='router')
|
||||
|
||||
async def test_individual_override(self):
|
||||
os.environ.update(DECISION_PROVIDER='jev', DECISION_ROUTER_PROVIDER='current')
|
||||
self.assertFalse(client.selected('router'))
|
||||
self.assertTrue(client.selected('route_continuity'))
|
||||
os.environ.update(DECISION_PROVIDER='current', DECISION_ROUTER_PROVIDER='jev')
|
||||
self.assertTrue(client.selected('router'))
|
||||
self.assertFalse(client.selected('route_continuity'))
|
||||
os.environ['DECISION_JUDGE_ALUC_PROVIDER'] = 'jev'
|
||||
self.assertTrue(client.selected('judge.groundedness', 'judge_aluc'))
|
||||
os.environ['DECISION_JUDGE_GROUNDEDNESS_PROVIDER'] = 'current'
|
||||
self.assertFalse(client.selected('judge.groundedness', 'judge_aluc'))
|
||||
|
||||
async def test_typed_choices_predicates_scores_shared_request(self):
|
||||
os.environ['DECISION_ROUTER_PROVIDER'] = 'jev'
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])) as post:
|
||||
result = json.loads(await client.invoke(self.llm, [], decision_point='router',
|
||||
decision_questions=[client.choice('intent', ['billing']), client.predicate('allowed'), client.score('score')]))
|
||||
self.assertEqual((result['intent'], result['allowed'], result['score']), ('billing', True, 8))
|
||||
self.assertEqual(post.call_count, 1)
|
||||
self.llm.ainvoke.assert_not_awaited()
|
||||
|
||||
async def test_invalid_response_falls_back_once(self):
|
||||
os.environ['DECISION_ROUTER_PROVIDER'] = 'jev'
|
||||
for failure in ['missing', 'duplicate', 'choice', 'nan', 'missing_confidence', 'low_confidence', 'type', 'network']:
|
||||
with self.subTest(failure=failure):
|
||||
self.llm.ainvoke.reset_mock()
|
||||
def transport(body):
|
||||
if failure == 'network': raise TimeoutError()
|
||||
r = response(body['questions']); a = r['answers'][0]
|
||||
if failure == 'missing': r['answers'] = []
|
||||
if failure == 'duplicate': r['answers'] *= 2
|
||||
if failure == 'choice': a['choice'] = 'unknown'
|
||||
if failure == 'nan': a['confidence'] = float('nan')
|
||||
if failure == 'missing_confidence': a.pop('confidence')
|
||||
if failure == 'low_confidence': a['confidence'] = .1
|
||||
if failure == 'type': a['type'] = 'predicate'
|
||||
return r
|
||||
with patch.object(client, '_post', side_effect=transport):
|
||||
self.assertEqual(await self.call(), 'legacy')
|
||||
self.llm.ainvoke.assert_awaited_once()
|
||||
|
||||
async def test_no_fallback_surfaces_error(self):
|
||||
os.environ.update(DECISION_ROUTER_PROVIDER='jev', DECISION_FALLBACK_TO_CURRENT='false')
|
||||
with patch.object(client, '_post', return_value={'answers': []}):
|
||||
with self.assertRaises(ValueError): await self.call()
|
||||
self.llm.ainvoke.assert_not_awaited()
|
||||
|
||||
async def test_generative_bypass(self):
|
||||
os.environ['DECISION_PROVIDER'] = 'jev'
|
||||
with patch.object(client, '_post', side_effect=AssertionError('network')):
|
||||
self.assertEqual(await self.call(decision_generative=True), 'legacy')
|
||||
|
||||
async def test_invalid_provider_rejected(self):
|
||||
os.environ['DECISION_ROUTER_PROVIDER'] = 'typo'
|
||||
with self.assertRaises(ValueError): await self.call()
|
||||
|
||||
async def test_custom_renderer_failure_falls_back(self):
|
||||
os.environ['DECISION_ROUTER_PROVIDER'] = 'jev'
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
self.assertEqual(await self.call(decision_render=lambda d: d['missing']), 'legacy')
|
||||
|
||||
async def test_threshold_override(self):
|
||||
os.environ.update(DECISION_ROUTER_PROVIDER='jev', DECISION_ROUTER_MIN_CONFIDENCE='.99')
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
self.assertEqual(await self.call(), 'legacy')
|
||||
|
||||
async def test_predicate_rejects_bool_and_out_of_range(self):
|
||||
for probability in [True, -1, 2, float('inf'), None]:
|
||||
with self.subTest(probability=probability):
|
||||
with patch.object(client, '_post', return_value={'answers': [{'type': 'predicate', 'name': 'allowed', 'probability': probability}]}):
|
||||
with self.assertRaises(ValueError):
|
||||
await client.evaluate('guardrail', [], [client.predicate('allowed')])
|
||||
|
||||
async def test_transport_requires_key_before_request(self):
|
||||
os.environ.pop('AI_GATEWAY_API_KEY', None)
|
||||
with patch.object(client.urllib.request, 'urlopen') as request:
|
||||
with self.assertRaises(ValueError): client._post({})
|
||||
request.assert_not_called()
|
||||
|
||||
async def test_transport_endpoint_auth_and_timeout(self):
|
||||
os.environ.update(AI_GATEWAY_API_KEY='test-key', JEV_TIMEOUT_SECONDS='3')
|
||||
import io
|
||||
with patch.object(client.urllib.request, 'urlopen', return_value=io.StringIO('{"answers": []}')) as request:
|
||||
self.assertEqual(client._post({'model': 'typesafe-ai/jev'}), {'answers': []})
|
||||
req = request.call_args.args[0]
|
||||
self.assertEqual(req.full_url, 'https://ai-gateway.vercel.sh/v1/decisions')
|
||||
self.assertEqual(req.get_header('Authorization'), 'Bearer test-key')
|
||||
self.assertEqual(request.call_args.kwargs['timeout'], 3)
|
||||
|
||||
async def test_integrated_modules_compile(self):
|
||||
for path in (ROOT / 'libs/agent_framework/src/agent_framework').rglob('*.py'):
|
||||
with self.subTest(path=path): ast.parse(path.read_text(), filename=str(path))
|
||||
|
||||
|
||||
|
||||
def load_functions(relative_path, names, namespace):
|
||||
"""Execute actual function bodies in isolation from unavailable optional dependencies."""
|
||||
path = ROOT / 'libs/agent_framework/src/agent_framework' / relative_path
|
||||
tree = ast.parse(path.read_text())
|
||||
nodes = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
|
||||
module = ast.Module(body=[ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0)] + nodes, type_ignores=[])
|
||||
exec(compile(ast.fix_missing_locations(module), str(path), 'exec'), namespace)
|
||||
return namespace
|
||||
|
||||
|
||||
class DecisionCallsiteTests(unittest.IsolatedAsyncioTestCase):
|
||||
setUp = DecisionTransportTests.setUp
|
||||
|
||||
async def test_actual_continuity_classifier_jev(self):
|
||||
os.environ['DECISION_ROUTE_CONTINUITY_PROVIDER'] = 'jev'
|
||||
ns = load_functions('routing/continuity.py', {'_classify'}, {
|
||||
'json': json, 'decision_invoke': client.invoke, 'choice': client.choice,
|
||||
'ContinuityEvaluation': SimpleNamespace})
|
||||
self_obj = SimpleNamespace(llm=self.llm, profile_name='route_continuity',
|
||||
_agent_capabilities=lambda intents: {'billing': ['invoices']},
|
||||
_compact_history=lambda history: history, _parse_json=json.loads)
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
result = await ns['_classify'](self_obj, {}, text='continue', active_agent='billing', intents=[])
|
||||
self.assertEqual(result.decision, 'CONTINUE')
|
||||
self.assertGreater(result.confidence, .9)
|
||||
self.llm.ainvoke.assert_not_awaited()
|
||||
|
||||
async def test_actual_router_derives_agent_from_catalog(self):
|
||||
os.environ['DECISION_ROUTER_PROVIDER'] = 'jev'
|
||||
ns = load_functions('routing/enterprise_router.py', {'_route_by_llm'}, {
|
||||
'json': json, 'decision_invoke': client.invoke, 'choice': client.choice, 'RouteDecision': SimpleNamespace})
|
||||
intent = SimpleNamespace(enabled=True, name='invoice', agent='billing', description='Invoice', examples=[], mcp_tools=[], domain='telecom')
|
||||
self_obj = SimpleNamespace(llm=self.llm, intents=[intent], fallback_agent='fallback', _parse_json=json.loads,
|
||||
_agent_for_intent=lambda name: 'billing', _domain_for_intent=lambda name: 'telecom', _tools_for_intent=lambda name: ['read_invoice'])
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
result = await ns['_route_by_llm'](self_obj, 'invoice', {})
|
||||
self.assertEqual((result.intent, result.agent, result.mcp_tools), ('invoice', 'billing', ['read_invoice']))
|
||||
|
||||
async def test_actual_reconciliation_preserves_candidate_values(self):
|
||||
os.environ['DECISION_TRANSACTION_PARAMETER_RECONCILIATION_PROVIDER'] = 'jev'
|
||||
ns = load_functions('runtime/transaction_parameters.py', {'reconcile_transaction_parameters', '_response_text', '_coerce'}, {
|
||||
'json': json, 'logger': logging.getLogger('test'), 'parse_json_object': json.loads,
|
||||
'decision_invoke': client.invoke, 'selected': client.selected, 'choice': client.choice,
|
||||
'_EMPTY_VALUES': (None, '', {}, [])})
|
||||
self.llm.ainvoke.return_value = json.dumps({'fields': {
|
||||
'id': {'value': '42', 'decision': 'resolved', 'source': 'current'},
|
||||
'empty': {'value': None, 'decision': 'unresolved', 'source': ''}}})
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
result = await ns['reconcile_transaction_parameters'](self.llm, text='42', tool_name='read', parameter_names=['id', 'empty'])
|
||||
self.assertEqual(result['values'], {'id': '42'})
|
||||
self.assertEqual(result['decisions']['empty'], 'unresolved')
|
||||
self.llm.ainvoke.assert_awaited_once()
|
||||
|
||||
async def test_actual_calibrated_judge_and_generative_fallback(self):
|
||||
os.environ['DECISION_JUDGE_RQLT_PROVIDER'] = 'jev'
|
||||
ns = load_functions('judges/calibrated/llm_client.py', {'classify'}, {
|
||||
'decision_invoke': client.invoke, 'predicate': client.predicate, 'score': client.score,
|
||||
'choice': client.choice, '_parse_json': json.loads})
|
||||
self_obj = SimpleNamespace(llm=self.llm, default_profile='judge', _build_prompt=lambda task, payload: 'quality rubric')
|
||||
with patch.object(client, '_post', side_effect=lambda b: response(b['questions'])):
|
||||
result = await ns['classify'](self_obj, 'RQLT', {})
|
||||
self.assertTrue(result['allowed'])
|
||||
self.assertEqual(result['score'], 8)
|
||||
self.llm.ainvoke.assert_not_awaited()
|
||||
self.llm.ainvoke.return_value = '{"text":"fallback response"}'
|
||||
os.environ['DECISION_PROVIDER'] = 'jev'
|
||||
with patch.object(client, '_post', side_effect=AssertionError('no generative JEV')):
|
||||
self.assertEqual(await ns['classify'](self_obj, 'FALLBACK', {}), {'text': 'fallback response'})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
176
tests/test_jev_typesafe_adapter.py
Normal file
176
tests/test_jev_typesafe_adapter.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
from urllib.error import HTTPError
|
||||
import pytest
|
||||
from agent_framework.decisions import client
|
||||
from agent_framework.decisions.adapters.typesafe import build_request, normalize_answers, TypeSafeContractError
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_provider_unchanged(monkeypatch):
|
||||
monkeypatch.delenv("DECISION_PROVIDER", raising=False)
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="legacy")})()
|
||||
monkeypatch.setattr(client, "_post", lambda body: pytest.fail("JEV transport called"))
|
||||
assert await client.invoke(llm, [{"content": "x"}], decision_point="route_continuity",
|
||||
decision_questions=[], profile_name="route_continuity") == "legacy"
|
||||
llm.ainvoke.assert_awaited_once_with([{"content": "x"}], profile_name="route_continuity")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typesafe_noul(monkeypatch):
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_MODEL", "jev-latest")
|
||||
captured = []
|
||||
def transport(body):
|
||||
captured.append(body)
|
||||
return {"model": "jev-1.14.0", "answers": {"__jev_q0": {"type": "noul", "noul": .98}}}
|
||||
monkeypatch.setattr(client, "_post", transport)
|
||||
result = await client.evaluate("guardrail.pinj", [], [client.predicate("allowed")])
|
||||
assert result["allowed"] is True and result["confidence"] == pytest.approx(.98)
|
||||
assert captured[0]["questions"]["__jev_q0"]["type"] == "noul"
|
||||
assert "state" in captured[0] and "input" not in captured[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vercel_contract_not_modified(monkeypatch):
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://ai-gateway.vercel.sh/v1/decisions")
|
||||
captured = []
|
||||
def transport(body):
|
||||
captured.append(body)
|
||||
return {"answers": [{"name": "allowed", "type": "predicate", "probability": .95}]}
|
||||
monkeypatch.setattr(client, "_post", transport)
|
||||
result = await client.evaluate("guardrail.pinj", [], [client.predicate("allowed")])
|
||||
assert result["allowed"] is True
|
||||
assert "input" in captured[0] and "state" not in captured[0]
|
||||
assert isinstance(captured[0]["questions"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_direct_response_falls_back_once(monkeypatch):
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {}})
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="original")})()
|
||||
assert await client.invoke(llm, [], decision_point="guardrail.revprec",
|
||||
decision_questions=[client.predicate("allowed")]) == "original"
|
||||
llm.ainvoke.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_diagnostic_without_body(monkeypatch, caplog):
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
def reject(body):
|
||||
raise HTTPError("https://api.typesafe.ai/v1/systemone", 400, "SECRET_BODY", {}, None)
|
||||
monkeypatch.setattr(client, "_post", reject)
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="original")})()
|
||||
assert await client.invoke(llm, [], decision_point="route_continuity",
|
||||
decision_questions=[client.choice("decision", ["CONTINUE", "ROUTE"])]) == "original"
|
||||
assert "http_status=400" in caplog.text
|
||||
assert "SECRET_BODY" not in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_disabled_raises(monkeypatch):
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("DECISION_FALLBACK_TO_CURRENT", "false")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {}})
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock()})()
|
||||
with pytest.raises(TypeSafeContractError):
|
||||
await client.invoke(llm, [], decision_point="guardrail.pinj", decision_questions=[client.predicate("allowed")])
|
||||
llm.ainvoke.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_422_schema_diagnostic_redacts_user_data(monkeypatch, caplog):
|
||||
import io
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
def reject(body):
|
||||
error = {"detail": [{"type": "missing", "loc": ["body", "questions", "private-user-input", "choices"],
|
||||
"msg": "SECRET_PASSWORD", "input": "SECRET_PASSWORD"}]}
|
||||
raise HTTPError("https://api.typesafe.ai/v1/systemone", 422, "invalid", {},
|
||||
io.BytesIO(json.dumps(error).encode()))
|
||||
monkeypatch.setattr(client, "_post", reject)
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="original")})()
|
||||
assert await client.invoke(llm, [], decision_point="route_continuity",
|
||||
decision_questions=[client.choice("decision", ["CONTINUE", "ROUTE"])]) == "original"
|
||||
assert "http_status=422" in caplog.text
|
||||
assert "http_schema=missing:body.questions.choices" in caplog.text
|
||||
assert "SECRET_PASSWORD" not in caplog.text
|
||||
assert "private-user-input" not in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_low_confidence_has_specific_error_code(monkeypatch, caplog):
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.9")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {"__jev_q0": {"type": "noul", "noul": 0.6}}})
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="original")})()
|
||||
assert await client.invoke(llm, [], decision_point="guardrail.revprec",
|
||||
decision_questions=[client.predicate("allowed")]) == "original"
|
||||
assert "error_code=CONFIDENCE_BELOW_THRESHOLD" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revprec_noul_probability_not_misread_as_confidence(monkeypatch, caplog):
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.7")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {"__jev_q0": {"type": "noul", "noul": .61}}})
|
||||
with pytest.raises(ValueError, match="CONFIDENCE_BELOW_THRESHOLD"):
|
||||
await client.evaluate("guardrail.revprec", [], [client.predicate("allowed")])
|
||||
assert "('allowed', 0.61)" in caplog.text
|
||||
assert "confidence=0.6100" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revprec_negative_predicate_confidence(monkeypatch):
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.7")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {"__jev_q0": {"type": "noul", "noul": .12}}})
|
||||
result = await client.evaluate("guardrail.revprec", [], [client.predicate("allowed")])
|
||||
assert result["allowed"] is False
|
||||
assert result["confidence"] == pytest.approx(.88)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opt_in_choice_diagnostics_rejected_and_fallback(monkeypatch, caplog):
|
||||
import logging
|
||||
monkeypatch.setenv("DECISION_PROVIDER", "jev")
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_DECISION_DIAGNOSTICS", "true")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.60")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"model": "jev-1.14.0", "answers": {
|
||||
"__jev_q0_o0": {"type": "noul", "noul": .56},
|
||||
"__jev_q0_o1": {"type": "noul", "noul": .35}}})
|
||||
llm = type("LLM", (), {"ainvoke": AsyncMock(return_value="current-result")})()
|
||||
with caplog.at_level(logging.INFO, logger="agent_framework.decisions"):
|
||||
output = await client.invoke(llm, [{"content": "PRIVATE_CUSTOMER_TEXT"}],
|
||||
decision_point="transaction_intent_shift",
|
||||
decision_questions=[client.choice("decision", ["CONTINUE", "SHIFT"])])
|
||||
assert output == "current-result"
|
||||
assert "jev.choice.diagnostics" in caplog.text
|
||||
assert '"option_probabilities":[0.56,0.35]' in caplog.text
|
||||
assert "status=rejected_low_confidence" in caplog.text
|
||||
assert "PRIVATE_CUSTOMER_TEXT" not in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opt_in_choice_diagnostics_accepted(monkeypatch, caplog):
|
||||
import logging
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_DECISION_DIAGNOSTICS", "true")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.60")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"model": "jev-1.14.0", "answers": {
|
||||
"__jev_q0_o0": {"type": "noul", "noul": .2},
|
||||
"__jev_q0_o1": {"type": "noul", "noul": .88}}})
|
||||
with caplog.at_level(logging.INFO, logger="agent_framework.decisions"):
|
||||
result = await client.evaluate("transaction_intent_shift", [],
|
||||
[client.choice("decision", ["CONTINUE", "SHIFT"])])
|
||||
assert result["decision"] == "SHIFT"
|
||||
assert result["confidence"] == pytest.approx(.88)
|
||||
assert '"selected_option_index":1' in caplog.text
|
||||
assert "status=accepted" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_diagnostics_off_by_default(monkeypatch, caplog):
|
||||
import logging
|
||||
monkeypatch.delenv("JEV_DECISION_DIAGNOSTICS", raising=False)
|
||||
monkeypatch.setenv("JEV_ENDPOINT", "https://api.typesafe.ai/v1/systemone")
|
||||
monkeypatch.setenv("JEV_MIN_CONFIDENCE", "0.6")
|
||||
monkeypatch.setattr(client, "_post", lambda body: {"answers": {
|
||||
"__jev_q0_o0": {"noul": .9}, "__jev_q0_o1": {"noul": .1}}})
|
||||
with caplog.at_level(logging.INFO, logger="agent_framework.decisions"):
|
||||
await client.evaluate("transaction_intent_shift", [], [client.choice("decision", ["A", "B"])])
|
||||
assert "jev.choice.diagnostics" not in caplog.text
|
||||
assert "jev.decision.diagnostics" not in caplog.text
|
||||
28
tests/test_jev_typesafe_direct.py
Normal file
28
tests/test_jev_typesafe_direct.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from agent_framework.decisions import client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_predicate_contract(monkeypatch):
|
||||
monkeypatch.setenv('JEV_ENDPOINT','https://api.typesafe.ai/v1/systemone')
|
||||
monkeypatch.setenv('JEV_MODEL','jev-latest')
|
||||
monkeypatch.setenv('JEV_MIN_CONFIDENCE','0.7')
|
||||
captured=[]
|
||||
def fake_post(body):
|
||||
captured.append(body)
|
||||
return {'model':'jev-latest','answers':{'billing':{'type':'noul','noul':0.98}}}
|
||||
monkeypatch.setattr(client,'_post',fake_post)
|
||||
result=await client.evaluate('test',[{'role':'user','content':'fatura'}],[client.predicate('billing')])
|
||||
assert result['billing'] is True
|
||||
assert result['confidence'] == pytest.approx(.98)
|
||||
assert captured[0]['model']=='jev-latest'
|
||||
assert 'state' in captured[0] and 'input' not in captured[0]
|
||||
assert captured[0]['questions']['billing']['type']=='noul'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_missing_answers_rejected(monkeypatch):
|
||||
monkeypatch.setenv('JEV_ENDPOINT','https://api.typesafe.ai/v1/systemone')
|
||||
monkeypatch.setattr(client,'_post',lambda body:{'answers':{}})
|
||||
with pytest.raises(ValueError):
|
||||
await client.evaluate('test',[],[client.predicate('billing')])
|
||||
Reference in New Issue
Block a user