annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Task recipes, physical batching and speculative error isolation; no model calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from annoscript._data import thaw
|
|
17
|
+
from annoscript._request_adapters import CONFIGURABLE_DECISION_ADAPTER, normalize_decision_options
|
|
18
|
+
from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError, DecisionBatchPlan
|
|
19
|
+
from annoscript.backends.base import normalize_call, encode_payload
|
|
20
|
+
from annoscript.backends.config import backends_from_env
|
|
21
|
+
from annoscript.client import AnnoClient
|
|
22
|
+
from annoscript.compiler import compile_program
|
|
23
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
24
|
+
from annoscript.preview import preview_request, main
|
|
25
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
26
|
+
from annoscript.server.releases import Releases, manifest
|
|
27
|
+
from annoscript.server.worker import ObservedBackend, backends as worker_backends
|
|
28
|
+
from annoscript._dispatch_pool import RequestSlots
|
|
29
|
+
from annoscript.test_decision_wire import answers_for
|
|
30
|
+
from annoscript.test_request_adapters import configuration, spec
|
|
31
|
+
from annoscript.test_runtime import FakeBackend, mixed
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def recipe(criteria='preserve', examples='preserve', mode='full', **state):
|
|
35
|
+
return {'criteria': criteria, 'examples': examples, 'state': {'mode': mode, **state}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def plain(mode='full', **state):
|
|
39
|
+
return recipe('text', 'omit', mode, **state)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backend(options=None, **kwargs):
|
|
43
|
+
return DecisionAPIBackend('http://offline.invalid/decision', 'secret', 'unchanged-model',
|
|
44
|
+
request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options=options, **kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def call(context, identity='a', name='tone', operation='choice', examples=True):
|
|
48
|
+
kwargs = {'instructions': '独立任务', 'field_name': name}
|
|
49
|
+
if operation == 'choice': kwargs['criteria'] = {'甲': '类别甲', '乙': '类别乙'}
|
|
50
|
+
if operation == 'score': kwargs['criteria'] = ['低', '高']
|
|
51
|
+
if examples:
|
|
52
|
+
kwargs['examples'] = [{'state': {'text': '示例一', 'meta': ['仅示例']},
|
|
53
|
+
'answer': {'choice': '甲', 'score': 0, 'noul': False}[operation]},
|
|
54
|
+
{'state': {'text': '示例二'},
|
|
55
|
+
'answer': {'choice': '乙', 'score': 1, 'noul': True}[operation]}]
|
|
56
|
+
return normalize_call(identity, operation, (), kwargs, context)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RepresentationTests(unittest.TestCase):
|
|
60
|
+
def test_preserve_unwrap_and_projection_without_mutating_inputs(self):
|
|
61
|
+
context = InferenceContext({'text': '当前', 'meta': ['当前元数据']})
|
|
62
|
+
c = call(context)
|
|
63
|
+
before = thaw(c.arguments)
|
|
64
|
+
for criteria, examples in [('preserve', 'preserve'), ('preserve', 'omit'), ('text', 'omit')]:
|
|
65
|
+
for state in [{'mode': 'full'}, {'mode': 'fields_object', 'fields': ['text']}, {'mode': 'field_string', 'field': 'text'}]:
|
|
66
|
+
r = {'criteria': criteria, 'examples': examples, 'state': state}
|
|
67
|
+
b = backend({'tasks': {'tone': r}})
|
|
68
|
+
value = b.preview_batch(context, [c])['payload']
|
|
69
|
+
self.assertEqual(list(value['questions']['tone']['criteria']), ['甲', '乙'])
|
|
70
|
+
expected = {'full': thaw(context.state), 'fields_object': {'text': '当前'}, 'field_string': '当前'}[state['mode']]
|
|
71
|
+
self.assertEqual(value['state'], expected)
|
|
72
|
+
criterion = value['questions']['tone']['criteria']['甲']
|
|
73
|
+
if criteria == 'text': self.assertEqual(criterion, '类别甲')
|
|
74
|
+
else:
|
|
75
|
+
self.assertEqual(criterion['what'], '类别甲')
|
|
76
|
+
if examples == 'preserve':
|
|
77
|
+
projected = {'full': {'text': '示例一', 'meta': ['仅示例']}, 'fields_object': {'text': '示例一'}, 'field_string': '示例一'}[state['mode']]
|
|
78
|
+
self.assertEqual(criterion['examples'], [projected])
|
|
79
|
+
else: self.assertNotIn('examples', criterion)
|
|
80
|
+
self.assertEqual(thaw(c.arguments), before)
|
|
81
|
+
self.assertEqual(thaw(context.state), {'text': '当前', 'meta': ['当前元数据']})
|
|
82
|
+
|
|
83
|
+
def test_task_matching_is_exact_with_default_for_unnamed_tasks(self):
|
|
84
|
+
ctx = InferenceContext({'text': '输入'})
|
|
85
|
+
b = backend({'tasks': {'tone': plain()}})
|
|
86
|
+
calls = [call(ctx, str(i), name) for i, name in enumerate(['tone', ' tone ', 'tone_extra', None])]
|
|
87
|
+
payload = b.preview_batch(ctx, calls)['payload']
|
|
88
|
+
self.assertEqual(payload['questions']['tone']['criteria']['甲'], '类别甲')
|
|
89
|
+
for key in [' tone ', 'tone_extra', '3']:
|
|
90
|
+
self.assertIn('examples', payload['questions'][key]['criteria']['甲'])
|
|
91
|
+
self.assertEqual(len(b.preview_batches(ctx, calls)['requests']), 1)
|
|
92
|
+
|
|
93
|
+
def test_empty_and_missing_projection_and_example_errors_are_local(self):
|
|
94
|
+
for state in ['正文', [], {}, {'text': []}]:
|
|
95
|
+
ctx = InferenceContext(state)
|
|
96
|
+
b = backend({'default': plain('field_string', field='text')})
|
|
97
|
+
with self.subTest(state=state), self.assertRaises(InferenceError) as caught:
|
|
98
|
+
b.preview_batch(ctx, [call(ctx)])
|
|
99
|
+
self.assertEqual(caught.exception.code, 'invalid_state_projection')
|
|
100
|
+
self.assertEqual(caught.exception.call_id, 'a')
|
|
101
|
+
self.assertEqual(caught.exception.metadata['dispatch_status'], 'not_sent')
|
|
102
|
+
self.assertIsNone(b._client)
|
|
103
|
+
ctx = InferenceContext({'text': ''})
|
|
104
|
+
b = backend({'default': plain('field_string', field='text')})
|
|
105
|
+
self.assertEqual(b.preview_batch(ctx, [call(ctx)])['payload']['state'], '')
|
|
106
|
+
kept = backend({'default': recipe(mode='field_string', field='text')})
|
|
107
|
+
c = normalize_call('x', 'choice', ('任务', {'甲': '甲'}),
|
|
108
|
+
{'examples': [{'state': {'other': 'missing'}, 'answer': '甲'}]}, ctx)
|
|
109
|
+
with self.assertRaises(InferenceError): kept.preview_batch(ctx, [c])
|
|
110
|
+
self.assertEqual(b.preview_batch(ctx, [c])['payload']['state'], '')
|
|
111
|
+
|
|
112
|
+
def test_actual_shared_state_controls_compatibility_and_prefix_order(self):
|
|
113
|
+
opts = {'tasks': {'tone': plain('field_string', field='text')}}
|
|
114
|
+
b = backend(opts)
|
|
115
|
+
ctx = InferenceContext({'text': '输入', 'brand': ['甲']})
|
|
116
|
+
calls = [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand'), call(ctx, 'c', 'tone')]
|
|
117
|
+
plan = b.preview_batches(ctx, calls)
|
|
118
|
+
self.assertEqual([list(v['evidence']['wire_call_ids'].values()) for v in plan['requests']], [['a'], ['b'], ['c']])
|
|
119
|
+
self.assertEqual([v['payload']['state'] for v in plan['requests']], ['输入', thaw(ctx.state), '输入'])
|
|
120
|
+
with self.assertRaises(InferenceError) as caught: b.preview_batch(ctx, calls[:2])
|
|
121
|
+
self.assertEqual(caught.exception.code, 'incompatible_decision_batch')
|
|
122
|
+
# Different view definitions may share the same actual object bytes.
|
|
123
|
+
ctx = InferenceContext({'text': '输入'})
|
|
124
|
+
b = backend({'tasks': {'tone': plain('fields_object', fields=['text'])}})
|
|
125
|
+
self.assertEqual(len(b.plan_batch(ctx, [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand')], max_calls=32).calls), 2)
|
|
126
|
+
# Object key order is model-visible and must not be canonicalized away.
|
|
127
|
+
ctx = InferenceContext({'x': 1, 'text': '输入'})
|
|
128
|
+
b = backend({'tasks': {'tone': plain('fields_object', fields=['text', 'x'])}})
|
|
129
|
+
self.assertEqual(len(b.plan_batch(ctx, [call(ctx, 'a', 'tone'), call(ctx, 'b', 'brand')], max_calls=32).calls), 1)
|
|
130
|
+
|
|
131
|
+
def test_limits_validation_and_duplicate_names_are_not_hidden(self):
|
|
132
|
+
ctx = InferenceContext({'text': '输入'})
|
|
133
|
+
calls = [call(ctx, str(i), 'n' + str(i)) for i in range(5)]
|
|
134
|
+
b = backend()
|
|
135
|
+
self.assertEqual([len(p['payload']['questions']) for p in b.preview_batches(ctx, calls, max_calls=2)['requests']], [2, 2, 1])
|
|
136
|
+
for limit in [0, -1, True, 1.5, None]:
|
|
137
|
+
with self.assertRaises(ValueError): b.plan_batch(ctx, calls, max_calls=limit)
|
|
138
|
+
duplicate = [call(ctx, 'a'), call(ctx, 'b')]
|
|
139
|
+
self.assertIsNone(b.plan_batch(ctx, duplicate, max_calls=32).prepared)
|
|
140
|
+
with self.assertRaisesRegex(ValueError, 'duplicate model-visible'): b.preview_batch(ctx, duplicate)
|
|
141
|
+
|
|
142
|
+
def test_invalid_options_are_rejected_before_transport_and_detached(self):
|
|
143
|
+
invalid = [False, [], {'extra': {}}, {'default': {}}, {'tasks': []}, {'tasks': {'': plain()}},
|
|
144
|
+
{'default': recipe('text', 'preserve')}, {'default': recipe(mode='unknown')},
|
|
145
|
+
{'default': recipe(mode='field_string', field=' ')}, {'default': recipe(mode='full', field='x')},
|
|
146
|
+
{'default': recipe(mode='fields_object', fields=[])}, {'default': recipe(mode='fields_object', fields=['x', 'x'])}]
|
|
147
|
+
for options in invalid:
|
|
148
|
+
with self.subTest(options=options), patch('annoscript.backends.base.HTTPBackend.__init__', side_effect=AssertionError('transport')):
|
|
149
|
+
with self.assertRaises(ValueError): backend(options)
|
|
150
|
+
source = {'tasks': {'tone': plain()}}
|
|
151
|
+
b = backend(source)
|
|
152
|
+
source['tasks']['tone']['criteria'] = 'preserve'
|
|
153
|
+
self.assertEqual(b.request_adapter_options['tasks']['tone']['criteria'], 'text')
|
|
154
|
+
with self.assertRaises(TypeError): b.request_adapter_options['tasks']['tone']['criteria'] = 'preserve'
|
|
155
|
+
with self.assertRaises(AttributeError): b.request_adapter_options = {}
|
|
156
|
+
with self.assertRaises(ValueError): DecisionAPIBackend('http://offline.invalid', 'secret', 'm', request_adapter_options=source)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class DecisionHTTPTests(unittest.IsolatedAsyncioTestCase):
|
|
160
|
+
async def test_score_legends_and_noul_without_invented_descriptions(self):
|
|
161
|
+
ctx = InferenceContext({'text': '输入'})
|
|
162
|
+
sent = []
|
|
163
|
+
def handle(req):
|
|
164
|
+
body = json.loads(req.content); sent.append(body)
|
|
165
|
+
return httpx.Response(200, json={'answers': answers_for(body)})
|
|
166
|
+
b = backend({'default': plain()}, transport=httpx.MockTransport(handle))
|
|
167
|
+
try:
|
|
168
|
+
calls = [call(ctx, 's', 'score', 'score'), call(ctx, 'n', 'boolean', 'noul')]
|
|
169
|
+
result = await b.infer_batch(ctx, calls)
|
|
170
|
+
self.assertEqual(result.responses['s'].legend, {0: '低', 1: '高'})
|
|
171
|
+
self.assertEqual(result.responses['n'].noul, .8)
|
|
172
|
+
self.assertEqual(sent[0]['questions']['score']['criteria'], ['低', '高'])
|
|
173
|
+
self.assertNotIn('criteria', sent[0]['questions']['boolean'])
|
|
174
|
+
finally: await b.aclose()
|
|
175
|
+
|
|
176
|
+
async def test_prepared_requests_bound_to_options_calls_context_and_backend(self):
|
|
177
|
+
ctx = InferenceContext({'text': '输入'})
|
|
178
|
+
calls = [call(ctx)]
|
|
179
|
+
received = []
|
|
180
|
+
def handle(req):
|
|
181
|
+
received.append(req.content)
|
|
182
|
+
return httpx.Response(200, json={'answers': answers_for(json.loads(req.content))})
|
|
183
|
+
a = backend(transport=httpx.MockTransport(handle))
|
|
184
|
+
b = backend({'default': plain()}, transport=httpx.MockTransport(handle))
|
|
185
|
+
try:
|
|
186
|
+
prepared = a.plan_batch(ctx, calls, max_calls=32).prepared
|
|
187
|
+
self.assertIsNotNone(prepared)
|
|
188
|
+
await b.infer_batch(ctx, calls, prepared=prepared)
|
|
189
|
+
self.assertEqual(received[-1], encode_payload(b.preview_batch(ctx, calls)['payload']))
|
|
190
|
+
self.assertNotEqual(received[-1], prepared.body)
|
|
191
|
+
# Valid preparation is sent without reconstructing the body.
|
|
192
|
+
plan = b.plan_batch(ctx, calls, max_calls=32)
|
|
193
|
+
with patch.object(b, '_batch_payload', side_effect=AssertionError('rebuilt')):
|
|
194
|
+
await b.infer_batch(ctx, calls, prepared=plan.prepared)
|
|
195
|
+
self.assertEqual(received[-1], plan.prepared.body)
|
|
196
|
+
changed = [call(ctx, 'other', 'other')]
|
|
197
|
+
await b.infer_batch(ctx, changed, prepared=plan.prepared)
|
|
198
|
+
self.assertIn('other', json.loads(received[-1])['questions'])
|
|
199
|
+
other_ctx = InferenceContext({'text': '另一个输入'})
|
|
200
|
+
await b.infer_batch(other_ctx, calls, prepared=plan.prepared)
|
|
201
|
+
self.assertEqual(json.loads(received[-1])['state'], {'text': '另一个输入'})
|
|
202
|
+
finally:
|
|
203
|
+
await a.aclose(); await b.aclose()
|
|
204
|
+
|
|
205
|
+
async def test_incompatible_direct_batch_sends_nothing_and_partial_answers_keep_ids(self):
|
|
206
|
+
ctx = InferenceContext({'text': '输入', 'meta': []}); sent = []
|
|
207
|
+
calls = [call(ctx, 'a', 'tone'), call(ctx, 'b', 'other')]
|
|
208
|
+
def handle(req):
|
|
209
|
+
sent.append(req.content); body = json.loads(req.content)
|
|
210
|
+
answers = answers_for(body); answers.pop(next(reversed(answers)))
|
|
211
|
+
return httpx.Response(200, json={'answers': answers})
|
|
212
|
+
b = backend({'tasks': {'tone': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
|
|
213
|
+
try:
|
|
214
|
+
with self.assertRaises(InferenceError): await b.infer_batch(ctx, calls)
|
|
215
|
+
self.assertFalse(sent)
|
|
216
|
+
finally: await b.aclose()
|
|
217
|
+
b = backend(transport=httpx.MockTransport(handle))
|
|
218
|
+
try:
|
|
219
|
+
result = await b.infer_batch(ctx, calls, allow_partial=True)
|
|
220
|
+
self.assertEqual(set(result.responses) | set(result.errors), {'a', 'b'})
|
|
221
|
+
self.assertEqual(len(sent), 1)
|
|
222
|
+
self.assertTrue(all(e.call_id == k for k, e in result.errors.items()))
|
|
223
|
+
finally: await b.aclose()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def ordered_tasks(anno):
|
|
227
|
+
a = anno.choice('body first', {'yes': '成立', 'no': '不成立'}, field_name='body').choice
|
|
228
|
+
b = anno.noul('brand', field_name='brand').noul
|
|
229
|
+
c = anno.choice('body last', {'yes': '成立', 'no': '不成立'}, field_name='body').choice
|
|
230
|
+
return [a, b, c]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def projection_branch(anno):
|
|
234
|
+
a = anno.choice('gate', {'yes': '成立', 'no': '不成立'}, field_name='gate').choice
|
|
235
|
+
if a == 'no':
|
|
236
|
+
return anno.noul('bad projection', field_name='bad').noul
|
|
237
|
+
return anno.noul('good projection', field_name='good').noul
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def selected_invalid(anno):
|
|
241
|
+
return anno.noul('selected invalid', field_name='bad').noul
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class DecisionRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|
245
|
+
async def run_program(self, function, b, *, mode='speculative', **options):
|
|
246
|
+
async with AnnoRuntime(b, FakeBackend(), trace=True, **options) as runtime:
|
|
247
|
+
results = [r async for r in await runtime.submit(lower_to_plan(compile_program(function)),
|
|
248
|
+
[RunInput(0, {'text': '输入', 'brand': ['甲']})], mode=mode)]
|
|
249
|
+
return results[0], runtime.trace
|
|
250
|
+
|
|
251
|
+
async def test_ordered_split_all_modes_and_one_slot_per_physical_request(self):
|
|
252
|
+
for mode in ['eager', 'strict', 'speculative']:
|
|
253
|
+
sent, received, active, peak = [], [], 0, 0
|
|
254
|
+
async def handle(req):
|
|
255
|
+
nonlocal active, peak
|
|
256
|
+
body = json.loads(req.content); sent.append(body); received.append(req.content); active += 1; peak = max(peak, active)
|
|
257
|
+
await asyncio.sleep(.005); active -= 1
|
|
258
|
+
return httpx.Response(200, json={'answers': answers_for(body)})
|
|
259
|
+
slots = RequestSlots(decision=1, generation=1)
|
|
260
|
+
b = backend({'tasks': {'body': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
|
|
261
|
+
ctx = InferenceContext({'text': '输入', 'brand': ['甲']})
|
|
262
|
+
calls = [normalize_call('a', 'choice', ('body first', {'yes': '成立', 'no': '不成立'}), {'field_name': 'body'}, ctx),
|
|
263
|
+
normalize_call('b', 'noul', ('brand',), {'field_name': 'brand'}, ctx),
|
|
264
|
+
normalize_call('c', 'choice', ('body last', {'yes': '成立', 'no': '不成立'}), {'field_name': 'body'}, ctx)]
|
|
265
|
+
preview = b.preview_batches(ctx, calls)
|
|
266
|
+
observed = ObservedBackend(b, [], lambda: True)
|
|
267
|
+
result, trace = await self.run_program(ordered_tasks, observed, mode=mode, request_slots=slots)
|
|
268
|
+
self.assertEqual(result.status, 'success', result.error)
|
|
269
|
+
self.assertEqual(result.value, ['yes', .8, 'yes'])
|
|
270
|
+
self.assertEqual([list(v['questions']) for v in sent], [['body'], ['brand'], ['body']])
|
|
271
|
+
self.assertEqual(received, [encode_payload(v['payload']) for v in preview['requests']])
|
|
272
|
+
self.assertEqual(len([e for e in trace if e['event'] == 'http_submitted']), 3)
|
|
273
|
+
self.assertEqual(peak, 1); self.assertEqual(slots.active['decision'], 0)
|
|
274
|
+
|
|
275
|
+
async def test_projection_errors_are_isolated_and_discarded_when_not_adopted(self):
|
|
276
|
+
for function, expected in [(projection_branch, 'success'), (selected_invalid, 'error')]:
|
|
277
|
+
sent = []
|
|
278
|
+
def handle(req):
|
|
279
|
+
body = json.loads(req.content); sent.append(body)
|
|
280
|
+
return httpx.Response(200, json={'answers': answers_for(body)})
|
|
281
|
+
b = backend({'tasks': {'bad': plain('field_string', field='missing')}}, transport=httpx.MockTransport(handle))
|
|
282
|
+
result, trace = await self.run_program(function, b)
|
|
283
|
+
self.assertEqual(result.status, expected, result.error)
|
|
284
|
+
self.assertFalse(any('bad' in v['questions'] for v in sent))
|
|
285
|
+
if function is projection_branch:
|
|
286
|
+
self.assertEqual(result.value, .8)
|
|
287
|
+
self.assertTrue(any(e['event'] == 'speculation_error_deferred' for e in trace))
|
|
288
|
+
else: self.assertEqual(result.error.code, 'invalid_state_projection')
|
|
289
|
+
|
|
290
|
+
async def test_custom_backend_without_planning_keeps_existing_batching(self):
|
|
291
|
+
b = FakeBackend()
|
|
292
|
+
result, trace = await self.run_program(mixed, b, mode='eager', max_batch_questions=2)
|
|
293
|
+
self.assertEqual(result.status, 'success', result.error)
|
|
294
|
+
self.assertEqual([len(v[1]) for v in b.batches], [2, 1])
|
|
295
|
+
|
|
296
|
+
async def test_split_partial_response_error_is_only_observed_on_selected_path(self):
|
|
297
|
+
for selected in ['yes', 'no']:
|
|
298
|
+
sent = []
|
|
299
|
+
def handle(req):
|
|
300
|
+
body = json.loads(req.content); sent.append(body); answers = answers_for(body)
|
|
301
|
+
if 'gate' in answers:
|
|
302
|
+
answers['gate'].update(choice=selected, probabilities={'yes': float(selected == 'yes'), 'no': float(selected == 'no')})
|
|
303
|
+
if 'bad' in answers: answers['bad']['noul'] = 'invalid'
|
|
304
|
+
return httpx.Response(200, json={'answers': answers})
|
|
305
|
+
b = backend({'tasks': {'bad': plain('field_string', field='text'), 'good': plain('field_string', field='text')}},
|
|
306
|
+
transport=httpx.MockTransport(handle))
|
|
307
|
+
result, trace = await self.run_program(projection_branch, ObservedBackend(b, [], lambda: True))
|
|
308
|
+
self.assertEqual(result.status, 'success' if selected == 'yes' else 'error', result.error)
|
|
309
|
+
self.assertEqual(sum('bad' in v['questions'] for v in sent), 1)
|
|
310
|
+
if selected == 'yes': self.assertEqual(result.value, .8)
|
|
311
|
+
else: self.assertEqual(result.error.code, 'invalid_response')
|
|
312
|
+
|
|
313
|
+
async def test_runtime_question_limit_and_invalid_planner_release_slots(self):
|
|
314
|
+
for limit in [1, 2]:
|
|
315
|
+
sent = []
|
|
316
|
+
def handle(req):
|
|
317
|
+
body = json.loads(req.content); sent.append(body)
|
|
318
|
+
return httpx.Response(200, json={'answers': answers_for(body)})
|
|
319
|
+
b = backend(transport=httpx.MockTransport(handle))
|
|
320
|
+
result, trace = await self.run_program(mixed, b, mode='eager', max_batch_questions=limit)
|
|
321
|
+
self.assertEqual(result.status, 'success', result.error)
|
|
322
|
+
self.assertEqual([len(v['questions']) for v in sent], [1, 1, 1] if limit == 1 else [2, 1])
|
|
323
|
+
slots = RequestSlots(decision=1, generation=1)
|
|
324
|
+
b = backend(transport=httpx.MockTransport(lambda req: self.fail('HTTP')))
|
|
325
|
+
def invalid(context, calls, *, max_calls):
|
|
326
|
+
return DecisionBatchPlan(tuple(reversed(calls)), None)
|
|
327
|
+
with patch.object(b, 'plan_batch', side_effect=invalid), self.assertRaises(InferenceError) as caught:
|
|
328
|
+
await self.run_program(mixed, b, mode='eager', request_slots=slots)
|
|
329
|
+
self.assertEqual(caught.exception.code, 'runtime_failed')
|
|
330
|
+
self.assertEqual(slots.active['decision'], 0)
|
|
331
|
+
|
|
332
|
+
async def test_cancellation_drains_inflight_slot_without_sending_other_prefixes(self):
|
|
333
|
+
entered, release = asyncio.Event(), asyncio.Event(); sent = []
|
|
334
|
+
async def handle(req):
|
|
335
|
+
body = json.loads(req.content); sent.append(body); entered.set(); await release.wait()
|
|
336
|
+
return httpx.Response(200, json={'answers': answers_for(body)})
|
|
337
|
+
slots = RequestSlots(decision=1, generation=1)
|
|
338
|
+
b = backend({'tasks': {'body': plain('field_string', field='text')}}, transport=httpx.MockTransport(handle))
|
|
339
|
+
runtime = AnnoRuntime(b, FakeBackend(), request_slots=slots)
|
|
340
|
+
await runtime.__aenter__()
|
|
341
|
+
try:
|
|
342
|
+
stream = await runtime.submit(lower_to_plan(compile_program(ordered_tasks)), [RunInput(0, {'text': '输入'})], mode='speculative')
|
|
343
|
+
await asyncio.wait_for(entered.wait(), 2)
|
|
344
|
+
await stream.aclose()
|
|
345
|
+
self.assertEqual(len(sent), 1)
|
|
346
|
+
self.assertEqual(slots.active['decision'], 1)
|
|
347
|
+
release.set()
|
|
348
|
+
finally:
|
|
349
|
+
release.set(); await runtime.aclose()
|
|
350
|
+
self.assertEqual(len(sent), 1)
|
|
351
|
+
self.assertEqual(slots.active['decision'], 0)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class OptionsAndPreviewTests(unittest.TestCase):
|
|
355
|
+
def test_release_normalization_binding_and_worker_ignores_environment(self):
|
|
356
|
+
cfg = configuration()
|
|
357
|
+
cfg['decision'].update(request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options={'tasks': {'tone': plain()}})
|
|
358
|
+
m = manifest(cfg)
|
|
359
|
+
normalized = normalize_decision_options(CONFIGURABLE_DECISION_ADAPTER, cfg['decision']['request_adapter_options'])
|
|
360
|
+
self.assertEqual(m['decision']['request_adapter_options'], normalized)
|
|
361
|
+
explicit = deepcopy(cfg); explicit['decision']['request_adapter_options'] = normalized
|
|
362
|
+
self.assertEqual(Releases({'x': cfg}, 'x').default, Releases({'x': explicit}, 'x').default)
|
|
363
|
+
changed = deepcopy(cfg); changed['decision']['request_adapter_options']['tasks']['tone'] = plain('field_string', field='text')
|
|
364
|
+
self.assertNotEqual(Releases({'x': cfg}, 'x').default, Releases({'x': changed}, 'x').default)
|
|
365
|
+
with patch.dict(os.environ, {'ANNO_DECISION_AUTH_TOKEN': 'secret', 'ANNO_GENERATE_AUTH_TOKEN': 'secret',
|
|
366
|
+
'ANNO_DECISION_REQUEST_ADAPTER_OPTIONS': 'invalid'}, clear=True):
|
|
367
|
+
d, _ = worker_backends(m)
|
|
368
|
+
self.assertEqual(thaw(d.request_adapter_options), normalized)
|
|
369
|
+
bad = deepcopy(cfg); bad['decision']['request_adapter_options'] = None
|
|
370
|
+
with self.assertRaises(ValueError): manifest(bad)
|
|
371
|
+
|
|
372
|
+
def test_existing_job_retains_recipe_when_default_release_changes(self):
|
|
373
|
+
from annoscript.server.store import Store
|
|
374
|
+
from annoscript.test_jobs_server import payload
|
|
375
|
+
a = configuration()
|
|
376
|
+
a['decision'].update(request_adapter=CONFIGURABLE_DECISION_ADAPTER, request_adapter_options={'tasks': {'tone': plain()}})
|
|
377
|
+
b = deepcopy(a)
|
|
378
|
+
b['decision']['request_adapter_options']['tasks']['tone'] = plain('field_string', field='text')
|
|
379
|
+
old, new = Releases({'x': a}, 'x'), Releases({'x': b}, 'x')
|
|
380
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
381
|
+
store = Store(Path(folder) / 'jobs.sqlite')
|
|
382
|
+
first = store.submit('alice', 'same', payload(), old)
|
|
383
|
+
repeated = store.submit('alice', 'same', payload(), new)
|
|
384
|
+
fresh = store.submit('alice', 'fresh', payload(), new)
|
|
385
|
+
self.assertEqual(first['release_id'], repeated['release_id'])
|
|
386
|
+
self.assertEqual(fresh['release_id'], new.default)
|
|
387
|
+
self.assertNotEqual(first['release_id'], fresh['release_id'])
|
|
388
|
+
|
|
389
|
+
def test_environment_json_precedence_and_client_forwarding(self):
|
|
390
|
+
env = {f'ANNO_{p}_{k}': v for p in ['DECISION', 'GENERATE'] for k, v in
|
|
391
|
+
[('BASE_URL', 'http://offline.invalid'), ('AUTH_TOKEN', 'secret'), ('MODEL', 'unchanged')]}
|
|
392
|
+
env['ANNO_DECISION_REQUEST_ADAPTER'] = CONFIGURABLE_DECISION_ADAPTER
|
|
393
|
+
opts = {'tasks': {'tone': plain()}}
|
|
394
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
395
|
+
path = Path(folder) / 'env'
|
|
396
|
+
path.write_text("ANNO_DECISION_REQUEST_ADAPTER_OPTIONS='" + json.dumps(opts) + "'\n")
|
|
397
|
+
with patch.dict(os.environ, env, clear=True):
|
|
398
|
+
self.assertEqual(thaw(backends_from_env(env_file=path)[0].request_adapter_options)['tasks'], opts['tasks'])
|
|
399
|
+
env['ANNO_DECISION_REQUEST_ADAPTER_OPTIONS'] = '{"default":{},"default":{}}'
|
|
400
|
+
with patch.dict(os.environ, env, clear=True):
|
|
401
|
+
with self.assertRaises(ValueError): backends_from_env(env_file=path)
|
|
402
|
+
with AnnoClient.from_env(env_file=path, decision_request_adapter_options=opts) as client:
|
|
403
|
+
self.assertEqual(thaw(client.runtime.decision_backend.request_adapter_options)['tasks'], opts['tasks'])
|
|
404
|
+
for invalid in ['null', '[]', '{"x":NaN}', '']:
|
|
405
|
+
env['ANNO_DECISION_REQUEST_ADAPTER_OPTIONS'] = invalid
|
|
406
|
+
with patch.dict(os.environ, env, clear=True), self.assertRaises(ValueError): backends_from_env()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == '__main__':
|
|
410
|
+
unittest.main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Decision request layout and public names across real HTTP and runtime paths."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from annoscript._data import thaw
|
|
10
|
+
from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError
|
|
11
|
+
from annoscript.backends.base import normalize_call
|
|
12
|
+
from annoscript.compiler import compile_program
|
|
13
|
+
from annoscript.ir import Program
|
|
14
|
+
from annoscript.plan import ExecutionPlan
|
|
15
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
16
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
17
|
+
from annoscript.test_runtime import FakeBackend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def named_decisions(anno):
|
|
21
|
+
first = anno.choice("选择", {"a": "甲", "b": "乙"}, field_name="category").choice
|
|
22
|
+
second = anno.noul("判断", field_name="is_event").noul
|
|
23
|
+
third = anno.score("程度", ["低", "高"], field_name="severity").score
|
|
24
|
+
again = anno.choice("选择", {"a": "甲", "b": "乙"}, field_name="category").choice
|
|
25
|
+
return [first, second, third, again]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def answers_for(body):
|
|
29
|
+
answers = {}
|
|
30
|
+
for name, q in reversed(list(body["questions"].items())):
|
|
31
|
+
if q["type"] == "choice":
|
|
32
|
+
selected = next(iter(q["criteria"]))
|
|
33
|
+
answer = {"choice": selected, "confidence": 1.,
|
|
34
|
+
"probabilities": {key: float(key == selected) for key in q["criteria"]}}
|
|
35
|
+
elif q["type"] == "noul":
|
|
36
|
+
answer = {"noul": .8}
|
|
37
|
+
else:
|
|
38
|
+
answer = {"score": 1., "confidence": 1., "probabilities": {"0": 0., "1": 1.},
|
|
39
|
+
"legend": {str(i): value for i, value in enumerate(q["criteria"])}}
|
|
40
|
+
answers[name] = {"type": q["type"], **answer}
|
|
41
|
+
return answers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DecisionWireTests(unittest.IsolatedAsyncioTestCase):
|
|
45
|
+
def call(self, operation, identity="internal", *, context=None, **options):
|
|
46
|
+
args = {"choice": ("选择", {"a": "甲", "b": "乙"}),
|
|
47
|
+
"noul": ("判断",), "score": ("程度", ["低", "高"])}[operation]
|
|
48
|
+
return normalize_call(identity, operation, args, options, context or InferenceContext("当前输入"))
|
|
49
|
+
|
|
50
|
+
async def test_grouped_examples_preserve_states_order_and_original_arguments(self):
|
|
51
|
+
context = InferenceContext({"text": "当前事实"}, "共享规则仅供 LLM 使用")
|
|
52
|
+
states = [{"text": "示例甲", "nested": [1, {"answer": "数据"}]}, "示例乙", ["示例丙"]]
|
|
53
|
+
calls = [self.call(op, identity=op, field_name=name, requirements=["额外规则", "额外规则"],
|
|
54
|
+
examples=[{"state": state, "answer": answer} for state, answer in zip(states, answers)])
|
|
55
|
+
for op, name, answers in (("choice", "category", ["b", "a", "b"]),
|
|
56
|
+
("noul", "is_event", [False, True, False]),
|
|
57
|
+
("score", "severity", [1, 0, 1]))]
|
|
58
|
+
original = [thaw(c.arguments) for c in calls]
|
|
59
|
+
sent, events = [], []
|
|
60
|
+
def handle(request):
|
|
61
|
+
body = json.loads(request.content)
|
|
62
|
+
sent.append(body)
|
|
63
|
+
return httpx.Response(200, json={"answers": answers_for(body)})
|
|
64
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
|
|
65
|
+
transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
|
|
66
|
+
try:
|
|
67
|
+
result = await backend.infer_batch(context, calls)
|
|
68
|
+
finally:
|
|
69
|
+
await backend.aclose()
|
|
70
|
+
body = sent[0]
|
|
71
|
+
self.assertEqual(body["state"], thaw(context.state))
|
|
72
|
+
self.assertEqual(set(body), {"model", "state", "questions"})
|
|
73
|
+
self.assertEqual(list(body["questions"]), ["category", "is_event", "severity"])
|
|
74
|
+
for call, q in zip(calls, body["questions"].values()):
|
|
75
|
+
self.assertEqual(q["instructions"], call.arguments["instructions"] + "\n\n要求:\n额外规则")
|
|
76
|
+
self.assertEqual(set(q), {"type", "instructions", "criteria"})
|
|
77
|
+
self.assertEqual(body["questions"]["category"]["criteria"], {
|
|
78
|
+
"a": {"what": "甲", "examples": [states[1]]},
|
|
79
|
+
"b": {"what": "乙", "examples": [states[0], states[2]]}})
|
|
80
|
+
self.assertEqual(body["questions"]["is_event"]["criteria"], {
|
|
81
|
+
"false": {"examples": [states[0], states[2]]}, "true": {"examples": [states[1]]}})
|
|
82
|
+
self.assertEqual(body["questions"]["severity"]["criteria"], [
|
|
83
|
+
{"what": "低", "examples": [states[1]]}, {"what": "高", "examples": [states[0], states[2]]}])
|
|
84
|
+
self.assertEqual([thaw(c.arguments) for c in calls], original)
|
|
85
|
+
self.assertEqual(set(result.responses), {c.id for c in calls})
|
|
86
|
+
self.assertEqual(result.responses["score"].legend, {0: "低", 1: "高"})
|
|
87
|
+
self.assertTrue(any(e.get("wire_call_ids") == {"category": "choice", "is_event": "noul", "severity": "score"}
|
|
88
|
+
for e in events))
|
|
89
|
+
|
|
90
|
+
async def test_empty_examples_and_noul_descriptions(self):
|
|
91
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture")
|
|
92
|
+
context = InferenceContext("当前输入", "不应拼接")
|
|
93
|
+
try:
|
|
94
|
+
for op in ("choice", "noul", "score"):
|
|
95
|
+
plain = self.call(op)
|
|
96
|
+
expected = backend._batch_payload(context, [plain])
|
|
97
|
+
for examples in (None, []):
|
|
98
|
+
explicit = self.call(op, field_name=None, examples=examples)
|
|
99
|
+
self.assertEqual(explicit, plain)
|
|
100
|
+
self.assertEqual(backend._batch_payload(context, [explicit]), expected)
|
|
101
|
+
self.assertEqual(list(expected["questions"]), [plain.id])
|
|
102
|
+
self.assertEqual(expected["questions"][plain.id]["instructions"], plain.arguments["instructions"])
|
|
103
|
+
call = self.call("noul", criteria_false="不成立", criteria_true="成立",
|
|
104
|
+
examples=[{"state": "肯定示例", "answer": True}])
|
|
105
|
+
self.assertEqual(backend._batch_payload(context, [call])["questions"][call.id]["criteria"],
|
|
106
|
+
{"false": "不成立", "true": {"what": "成立", "examples": ["肯定示例"]}})
|
|
107
|
+
finally:
|
|
108
|
+
await backend.aclose()
|
|
109
|
+
|
|
110
|
+
async def test_invalid_names_and_collisions_fail_before_http(self):
|
|
111
|
+
sent = []
|
|
112
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
|
|
113
|
+
transport=httpx.MockTransport(lambda r: sent.append(r)))
|
|
114
|
+
try:
|
|
115
|
+
for op in ("choice", "noul", "score"):
|
|
116
|
+
for name in ("", " \t", 1, False, {}, []):
|
|
117
|
+
with self.subTest(op=op, name=name), self.assertRaisesRegex(ValueError, "field_name"):
|
|
118
|
+
self.call(op, field_name=name)
|
|
119
|
+
for calls in ([self.call("choice", "a", field_name="same"), self.call("score", "b", field_name="same")],
|
|
120
|
+
[self.call("choice", "a", field_name="b"), self.call("noul", "b")]):
|
|
121
|
+
with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
|
|
122
|
+
await backend.infer_batch(InferenceContext("输入"), calls)
|
|
123
|
+
self.assertEqual(sent, [])
|
|
124
|
+
finally:
|
|
125
|
+
await backend.aclose()
|
|
126
|
+
|
|
127
|
+
async def test_partial_answers_and_errors_keep_internal_ids(self):
|
|
128
|
+
name = ' 字段"\\\n😀 '
|
|
129
|
+
calls = [self.call("noul", "run-q0", field_name=name), self.call("choice", "run-q1")]
|
|
130
|
+
answers = {name: {"type": "noul", "noul": .8}}
|
|
131
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
|
|
132
|
+
transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"answers": answers})))
|
|
133
|
+
try:
|
|
134
|
+
result = await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
|
|
135
|
+
self.assertEqual(set(result.responses), {"run-q0"})
|
|
136
|
+
self.assertEqual(result.errors["run-q1"].call_id, "run-q1")
|
|
137
|
+
with self.assertRaises(InferenceError):
|
|
138
|
+
await backend.infer_batch(InferenceContext("输入"), calls)
|
|
139
|
+
answers[name]["noul"] = "invalid"
|
|
140
|
+
result = await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
|
|
141
|
+
self.assertEqual(set(result.errors), {"run-q0", "run-q1"})
|
|
142
|
+
answers["unexpected"] = {"type": "noul", "noul": .5}
|
|
143
|
+
with self.assertRaises(InferenceError):
|
|
144
|
+
await backend.infer_batch(InferenceContext("输入"), calls, allow_partial=True)
|
|
145
|
+
finally:
|
|
146
|
+
await backend.aclose()
|
|
147
|
+
|
|
148
|
+
async def test_score_legend_rejects_wrong_description_or_examples(self):
|
|
149
|
+
call = self.call("score", field_name="severity", examples=[{"state": "示例", "answer": 1}])
|
|
150
|
+
for legend in ({"0": "低", "1": "高"},
|
|
151
|
+
{"0": "低", "1": {"what": "高", "examples": ["示例"]}},
|
|
152
|
+
{"0": "低", "1": {"what": "高", "examples": ["错误示例"]}},
|
|
153
|
+
{"0": "低", "1": "错误描述"}):
|
|
154
|
+
def handle(request):
|
|
155
|
+
answers = answers_for(json.loads(request.content))
|
|
156
|
+
answers["severity"]["legend"] = legend
|
|
157
|
+
return httpx.Response(200, json={"answers": answers})
|
|
158
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
|
|
159
|
+
try:
|
|
160
|
+
if "错误" in json.dumps(legend, ensure_ascii=False):
|
|
161
|
+
with self.assertRaises(InferenceError):
|
|
162
|
+
await backend.infer_batch(InferenceContext("输入"), [call])
|
|
163
|
+
else:
|
|
164
|
+
result = await backend.infer_batch(InferenceContext("输入"), [call])
|
|
165
|
+
self.assertEqual(result.responses[call.id].legend, {0: "低", 1: "高"})
|
|
166
|
+
finally:
|
|
167
|
+
await backend.aclose()
|
|
168
|
+
|
|
169
|
+
async def test_named_decisions_roundtrip_and_reuse_in_all_runtime_modes(self):
|
|
170
|
+
for optimize in (False, True):
|
|
171
|
+
program = Program.from_json(compile_program(named_decisions, optimize=optimize).to_json())
|
|
172
|
+
plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
|
|
173
|
+
for mode in ("strict", "eager", "speculative"):
|
|
174
|
+
for limit in (1, 32):
|
|
175
|
+
sent = []
|
|
176
|
+
def handle(request):
|
|
177
|
+
body = json.loads(request.content)
|
|
178
|
+
sent.append(deepcopy(body))
|
|
179
|
+
return httpx.Response(200, json={"answers": answers_for(body)})
|
|
180
|
+
backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
|
|
181
|
+
async with AnnoRuntime(backend, FakeBackend(), max_batch_questions=limit) as runtime:
|
|
182
|
+
result, = [r async for r in await runtime.submit(plan, [RunInput(0, "输入")], mode=mode)]
|
|
183
|
+
self.assertEqual(result.status, "success", result.error)
|
|
184
|
+
self.assertEqual(result.value, ["a", .8, 1., "a"])
|
|
185
|
+
names = [name for body in sent for name in body["questions"]]
|
|
186
|
+
self.assertCountEqual(names, ["category", "is_event", "severity"])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
unittest.main()
|