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.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
@@ -0,0 +1,97 @@
1
+ """Opt-in raw diagnostics preserve wire behavior, failure isolation and usage."""
2
+ import asyncio,json,tempfile,threading,unittest
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+ import httpx
6
+ from annoscript.backends import DecisionAPIBackend, InferenceContext, InferenceError
7
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
8
+ from annoscript.backends.base import normalize_call
9
+ from annoscript.server.store import Store
10
+ from annoscript.server.releases import Releases
11
+ from annoscript.server.worker import Worker
12
+ from annoscript.test_jobs_server import configurations,payload
13
+ from annoscript.test_llm_batch import mixed_generation,answer,completion
14
+ from annoscript._test_evaluation_http import llm_content,llm_calls
15
+
16
+ class WireTests(unittest.IsolatedAsyncioTestCase):
17
+ async def test_raw_before_validation_and_same_requests_and_results(self):
18
+ for content,status,finish,usage in [('{"items":["广州"]}',200,'stop',{'prompt_tokens':5,'completion_tokens':2}),('{',200,'stop',None),('{"items":["北京"]}',200,'stop',{}),('{"items":[]}',200,'length',{}),('{"error":{"message":"context length"}}',400,None,None)]:
19
+ bodies=[];outcomes=[];events=[]
20
+ for enabled in (False,True):
21
+ def handler(req):
22
+ bodies.append(req.content)
23
+ body={'choices':[{'message':{'content':content},'finish_reason':finish}]}
24
+ if usage is not None:body['usage']=usage
25
+ return httpx.Response(status,json=body) if status==200 else httpx.Response(status,content=content)
26
+ b=LLMAPIBackend('https://offline.invalid','secret-fixture','qwen',profile='qwen_vllm',transport=httpx.MockTransport(handler),diagnostic_observer=events.append if enabled else None)
27
+ context=InferenceContext('广州');call=normalize_call('topic','extract',('地名',),{'max_items': 8, 'max_length_per_item': 58},context)
28
+ try:
29
+ result=await b.infer(context,call);outcomes.append(result.response.items)
30
+ except InferenceError as e:outcomes.append(e.code)
31
+ finally:await b.aclose()
32
+ self.assertEqual(bodies[0],bodies[1]);self.assertEqual(outcomes[0],outcomes[1])
33
+ wire_events=[e for e in events if e['event']!='advisory_lengths']
34
+ self.assertEqual([e['event'] for e in wire_events],['request','response','outcome'])
35
+ self.assertEqual(len({e['request_id'] for e in events}),1)
36
+ self.assertEqual(events[0]['body'],bodies[1]);self.assertTrue(events[1]['body'])
37
+ self.assertNotIn('secret-fixture',repr(events));self.assertEqual(wire_events[-1]['http_status'],status)
38
+
39
+ class WorkerTests(unittest.TestCase):
40
+ def setUp(self):
41
+ self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
42
+ self.store=Store(Path(self.temp.name)/'tasks.sqlite');self.releases=Releases(configurations(retry_unknown_outcomes=False),'test')
43
+ def worker(self,handler,**kw):
44
+ def backend(_):
45
+ transport=httpx.MockTransport(handler)
46
+ return DecisionAPIBackend('https://offline.invalid','secret','m',transport=transport),LLMAPIBackend('https://offline.invalid','secret','m',profile='qwen_vllm',transport=transport)
47
+ return Worker(self.store,self.releases,backend_factory=backend,**kw)
48
+ def submit(self,key,**kw):return self.store.submit('alice',key,payload(**kw),self.releases)
49
+ def test_batch_repair_raw_parent_usage_and_runtime_mapping(self):
50
+ calls=[];audit=[];raw=[]
51
+ def handler(req):
52
+ body=json.loads(req.content);calls.append(body);value=llm_content(body,answer)
53
+ if llm_calls(body)[0]:
54
+ v=json.loads(value);del v['answers'][next(iter(v['answers']))];value=json.dumps(v)
55
+ return completion(value)
56
+ job=self.submit('batch',states=('广州与北京',),program=mixed_generation)
57
+ asyncio.run(self.worker(handler,request_observer=audit.append,diagnostic_observer=raw.append).execute(self.store.claim('w',self.releases)))
58
+ self.assertEqual(self.store.status('alice',job['job_id'])['counts']['success'],1)
59
+ rr=[e for e in raw if e['event']=='request'];self.assertEqual(len(rr),2)
60
+ self.assertEqual(rr[1]['repair_parent_request_ids'],[rr[0]['request_id']]);self.assertTrue(rr[1]['repair'])
61
+ self.assertEqual({e['request_id'] for e in audit},{e['request_id'] for e in rr})
62
+ self.assertTrue(any(e['event']=='trace' and e['plan'] for e in raw))
63
+ def test_diagnostic_failure_pauses_without_model_retry_and_keeps_usage(self):
64
+ for phase,expected in [('request',0),('response',1),('outcome',1)]:
65
+ job=self.submit(phase,states=('one',));calls=[];audit=[]
66
+ def observe(event):
67
+ if event['event']==phase:raise OSError('disk fixture')
68
+ def handler(req):calls.append(req);return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':'yes'}}],'usage':{'prompt_tokens':5,'completion_tokens':1}})
69
+ asyncio.run(self.worker(handler,request_observer=audit.append,diagnostic_observer=observe).execute(self.store.claim('w',self.releases)))
70
+ self.assertEqual(len(calls),expected)
71
+ self.assertEqual(self.store.status('alice',job['job_id'])['pause_reason']['code'],'diagnostics_failed')
72
+ if expected:self.assertEqual([x for x in audit if x['event']=='outcome'][0]['usage']['prompt_tokens'],5)
73
+ def test_item_failure_completes_and_next_batch_can_be_claimed(self):
74
+ count=[]
75
+ for batch in range(2):
76
+ job=self.submit(str(batch),states=tuple(str(i) for i in range(20)))
77
+ def handler(req):count.append(req);return httpx.Response(200,content=b'{')
78
+ worker=self.worker(handler,diagnostic_observer=lambda e:None)
79
+ for _ in range(20):asyncio.run(worker.execute(self.store.claim('w',self.releases)))
80
+ self.assertEqual(self.store.status('alice',job['job_id'])['state'],'completed')
81
+ self.assertEqual(self.store.status('alice',job['job_id'])['counts']['error'],20)
82
+ self.assertEqual(len(count),40)
83
+ def test_cancel_during_commit_keeps_raw_response_and_matching_usage(self):
84
+ self.submit('cancel',states=('one',));entered=threading.Event();release=threading.Event();original=self.store.request_finished;raw=[];audit=[]
85
+ def finish(*a,**kw):entered.set();release.wait(5);return original(*a,**kw)
86
+ def handler(req):return httpx.Response(200,json={'choices':[{'finish_reason':'stop','message':{'content':'yes'}}],'usage':{'prompt_tokens':9,'completion_tokens':1}})
87
+ async def run():
88
+ with patch.object(self.store,'request_finished',side_effect=finish):
89
+ task=asyncio.create_task(self.worker(handler,request_observer=audit.append,diagnostic_observer=raw.append).execute(self.store.claim('w',self.releases)))
90
+ async with asyncio.timeout(5):
91
+ while not entered.is_set():await asyncio.sleep(.005)
92
+ task.cancel();release.set()
93
+ with self.assertRaises(asyncio.CancelledError):await task
94
+ asyncio.run(run())
95
+ self.assertEqual(len([e for e in raw if e['event']=='response']),1)
96
+ self.assertEqual([e for e in audit if e['event']=='outcome'][0]['usage']['prompt_tokens'],9)
97
+ self.assertEqual([e for e in raw if e['event']=='outcome'][0]['http_status'],200)
@@ -0,0 +1,191 @@
1
+ """Engineering P0 regressions: no live models or business-system access."""
2
+ import asyncio
3
+ from dataclasses import replace
4
+ import json
5
+ import time
6
+ import unittest
7
+ from unittest.mock import patch
8
+
9
+ import httpx
10
+
11
+ from annoscript._data import thaw
12
+ from annoscript._test_evaluation_http import llm_calls, llm_content
13
+ from annoscript.backends import InferenceContext, BackendFatalError, InferenceError
14
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
15
+ from annoscript.backends.base import normalize_call
16
+ from annoscript.compiler import compile_program
17
+ from annoscript.ir import Program
18
+ from annoscript.plan_lowering import lower_to_plan
19
+ from annoscript.server._plan_cache import PlanCache
20
+ from annoscript.server.worker import ObservedBackend, Worker
21
+ from annoscript.test_generation_policy import policy
22
+ from annoscript.test_llm_batch import answer, completion
23
+
24
+
25
+ def identity_program(anno):
26
+ return anno.state
27
+
28
+
29
+ class PlanCacheTests(unittest.IsolatedAsyncioTestCase):
30
+ async def test_concurrent_miss_cancel_and_release_isolation(self):
31
+ ir = compile_program(identity_program).to_dict()
32
+ cache = PlanCache()
33
+ compiled = []
34
+ def build(encoded):
35
+ time.sleep(.025)
36
+ compiled.append(encoded)
37
+ return lower_to_plan(Program.from_json(encoded.decode()))
38
+ try:
39
+ tasks = [asyncio.create_task(cache.get("a", ir, build)) for _ in range(4)]
40
+ await asyncio.sleep(.005)
41
+ tasks[0].cancel()
42
+ results = await asyncio.gather(*tasks, return_exceptions=True)
43
+ self.assertIsInstance(results[0], asyncio.CancelledError)
44
+ self.assertEqual(len(compiled), 1)
45
+ self.assertTrue(all(v[0] is results[1][0] for v in results[1:]))
46
+ self.assertEqual((await cache.get("a", ir, build))[1]["status"], "hit")
47
+ await cache.get("b", ir, build)
48
+ changed = json.loads(json.dumps(ir)); changed["source"]["line"] += 1
49
+ await cache.get("a", changed, build)
50
+ self.assertEqual(len(compiled), 3)
51
+ self.assertEqual(cache.stats["waits"], 3)
52
+ finally:
53
+ await cache.aclose()
54
+
55
+ async def test_lru_oversize_and_failure_not_cached(self):
56
+ ir = compile_program(identity_program).to_dict()
57
+ build = lambda b: lower_to_plan(Program.from_json(b.decode()))
58
+ for options in ({"max_entries": 1}, {"max_bytes": 1}):
59
+ cache = PlanCache(**options)
60
+ try:
61
+ await cache.get("a", ir, build)
62
+ await cache.get("b", ir, build)
63
+ _, event = await cache.get("a", ir, build)
64
+ self.assertEqual(event["status"], "miss")
65
+ self.assertLessEqual(len(cache.entries), options.get("max_entries", 32))
66
+ self.assertLessEqual(cache.bytes, options.get("max_bytes", 64*1024*1024))
67
+ def fail(b): raise ValueError("bad IR")
68
+ for _ in range(2):
69
+ with self.assertRaises(ValueError): await cache.get("failure", ir, fail)
70
+ self.assertEqual(cache.stats["failures"], 2)
71
+ finally:
72
+ await cache.aclose()
73
+
74
+
75
+ class PreparedRequestTests(unittest.IsolatedAsyncioTestCase):
76
+ def backend(self, events=None, profile="qwen_vllm", window=8192, counter=None):
77
+ self.sent = []
78
+ def handle(request):
79
+ self.sent.append(request)
80
+ return completion(llm_content(json.loads(request.content), answer))
81
+ return LLMAPIBackend("https://offline.invalid", "fixture", "fixture", profile=profile,
82
+ request_policy=policy(window) if profile == "qwen_vllm" else None,
83
+ _token_counter=counter or (lambda p: 10), transport=httpx.MockTransport(handle),
84
+ diagnostic_observer=events.append if events is not None else None)
85
+
86
+ def calls(self, ctx):
87
+ return [normalize_call("a", "generate", ("标题",), {"max_length": 8}, ctx),
88
+ normalize_call("b", "extract", ("地点",),
89
+ {"source_field": "text" if isinstance(ctx.state, dict) or hasattr(ctx.state, "keys") else None, "max_items": 2, "max_length_per_item": 8}, ctx)]
90
+
91
+ def plan(self, b, ctx, calls):
92
+ return b.plan_batch(ctx, calls, max_calls=8, max_input_bytes=262144,
93
+ max_output_tokens=4096, default_tokens=1024)
94
+
95
+ async def test_selected_payload_sent_without_rebuild_count_or_encoding(self):
96
+ events=[]; b=self.backend(events); ctx=InferenceContext({"text":"广州与北京"}, "业务策略")
97
+ calls=self.calls(ctx)
98
+ try:
99
+ p=self.plan(b,ctx,calls)
100
+ before=dict(b.preparation_stats)
101
+ self.assertIs(self.plan(b,ctx,calls),p)
102
+ with patch.object(b,"_prepare",side_effect=AssertionError("prepared again")):
103
+ out=await b.infer_batch(ctx,calls,max_tokens=p.max_tokens,prepared=p.prepared)
104
+ self.assertEqual(out.responses['b'].items,['广州'])
105
+ self.assertEqual(b.preparation_stats['token_counts'],before['token_counts'])
106
+ self.assertEqual(b.preparation_stats['encodes'],before['encodes'])
107
+ self.assertEqual(self.sent[0].content,p.prepared.body)
108
+ self.assertEqual(next(e['body'] for e in events if e['event']=='request'),self.sent[0].content)
109
+ self.assertTrue(thaw(p.prepared.payload)['messages'][0]['content'].startswith('业务策略\n'))
110
+ with self.assertRaises(TypeError): p.prepared.payload['model']='changed'
111
+ finally: await b.aclose()
112
+
113
+ async def test_stale_budget_context_calls_and_config_are_reprepared(self):
114
+ b=self.backend();ctx=InferenceContext({"text":"广州与北京"});calls=self.calls(ctx)
115
+ try:
116
+ p=self.plan(b,ctx,calls)
117
+ tests=[(ctx,calls,p.max_tokens+1),
118
+ (InferenceContext({"text":"广州与北京", "extra": "new"}),calls,p.max_tokens),
119
+ (ctx,[replace(calls[0],arguments={**calls[0].arguments,"instructions":"不同标题"}),calls[1]],p.max_tokens)]
120
+ for c,cs,budget in tests:
121
+ before=b.preparation_stats['payload_builds']
122
+ await b.infer_batch(c,cs,max_tokens=budget,prepared=p.prepared)
123
+ self.assertEqual(b.preparation_stats['payload_builds'],before+1)
124
+ self.assertEqual(json.loads(self.sent[-1].content)['max_tokens'],budget)
125
+ b.model="different"
126
+ self.assertIsNot(self.plan(b,ctx,calls),p)
127
+ finally: await b.aclose()
128
+
129
+ async def test_source_fields_single_repair_batch_and_other_profiles(self):
130
+ texts=['广州与北京','甲"乙\\丙\n😀e\u0301','', '\\u1234']
131
+ for profile in ['qwen_vllm','legacy']:
132
+ for text in texts:
133
+ for state in [text,{"text":text,"other":"独立来源"}]:
134
+ b=self.backend(profile=profile);ctx=InferenceContext(state)
135
+ c=normalize_call('e','extract',('原文',),{'source_field':None if isinstance(state,str) else 'text','max_items':2,'max_length_per_item':8},ctx)
136
+ try:
137
+ for feedback in [None,'bad structure']:
138
+ p=b._prepare(ctx,[c],80,repair_feedback=feedback)
139
+ content=thaw(p.payload)['messages'][-1]['content']
140
+ self.assertEqual(content if isinstance(state,str) else json.loads(content),state)
141
+ prompt=thaw(p.payload)['messages'][0]['content']
142
+ self.assertIn('输入字符串' if isinstance(state,str) else '输入的顶层字段 "text"',prompt)
143
+ self.assertEqual(llm_calls(thaw(p.payload))[1][0][1].extraction_source,text)
144
+ other = replace(c,id='other') if isinstance(state,str) else normalize_call('other','extract',('原文',),{'source_field':'other','max_items':1,'max_length_per_item':8},ctx)
145
+ p=self.plan(b,ctx,[c,other,replace(c,id='again')])
146
+ reconstructed=llm_calls(thaw(p.prepared.payload))[1]
147
+ self.assertEqual([v.extraction_source for _,v in reconstructed],[text,text if isinstance(state,str) else '独立来源',text])
148
+ content=thaw(p.prepared.payload)['messages'][-1]['content']
149
+ self.assertEqual(content if isinstance(state,str) else json.loads(content),state)
150
+ finally: await b.aclose()
151
+
152
+ async def test_prepared_pause_blocks_http_and_local_rejection_not_sent(self):
153
+ b=self.backend();ctx=InferenceContext('广州与北京');calls=self.calls(ctx)
154
+ p=self.plan(b,ctx,calls)
155
+ allowed=True
156
+ observed=ObservedBackend(b,[],lambda:True,dispatch_allowed=lambda:allowed)
157
+ allowed=False
158
+ try:
159
+ with self.assertRaises(BackendFatalError):
160
+ await observed.infer_batch(ctx,calls,max_tokens=p.max_tokens,prepared=p.prepared)
161
+ self.assertEqual(self.sent,[])
162
+ finally:await b.aclose()
163
+ b=self.backend(counter=lambda p:8192)
164
+ try:
165
+ with self.assertRaises(InferenceError) as e:await b.infer(ctx,calls[0])
166
+ self.assertEqual(e.exception.metadata['dispatch_status'],'not_sent')
167
+ self.assertEqual(self.sent,[])
168
+ finally:await b.aclose()
169
+
170
+
171
+ class WorkerRefillTests(unittest.IsolatedAsyncioTestCase):
172
+ async def test_completion_refills_before_poll_timeout(self):
173
+ stop=asyncio.Event();times=[]
174
+ class Store:
175
+ n=0
176
+ def maintain(self):pass
177
+ def claim(self,*args):
178
+ if self.n>=2:return None
179
+ self.n+=1;return self.n
180
+ class TestWorker(Worker):
181
+ async def execute(self,claim):
182
+ times.append(time.monotonic())
183
+ await asyncio.sleep(.01)
184
+ if claim==2:stop.set()
185
+ w=TestWorker(Store(),None,concurrency=1,poll_interval=1)
186
+ await asyncio.wait_for(w.run(stop=stop),timeout=.5)
187
+ self.assertEqual(len(times),2)
188
+ self.assertLess(times[1]-times[0],.2)
189
+
190
+
191
+ if __name__ == '__main__': unittest.main()
@@ -0,0 +1,65 @@
1
+ """Configuration compatibility across real file/process boundaries; no network."""
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+ import tempfile
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ from annoscript._env import env_value
11
+ from annoscript.backends.config import backends_from_env
12
+
13
+
14
+ class EnvironmentTests(unittest.TestCase):
15
+ def configured(self, file_values, environment, *, generation_profile=None):
16
+ with tempfile.TemporaryDirectory() as directory:
17
+ path = Path(directory) / "configuration"
18
+ path.write_text("\n".join(f"{k}={v}" for k, v in file_values.items()))
19
+ with patch.dict(os.environ, environment, clear=True):
20
+ decision, generation = backends_from_env(env_file=path, generation_profile=generation_profile)
21
+ try:
22
+ return decision.model, generation.model, generation.profile
23
+ finally:
24
+ async def close():
25
+ await decision.aclose()
26
+ await generation.aclose()
27
+ asyncio.run(close())
28
+
29
+ def baseline(self, prefix):
30
+ return {f"{prefix}_{stage}_{key}": value
31
+ for stage in ("DECISION", "GENERATE")
32
+ for key, value in (("BASE_URL", "https://example.invalid/v1"),
33
+ ("AUTH_TOKEN", "fixture"), ("MODEL", "file-model"))}
34
+
35
+ def test_legacy_file_remains_usable(self):
36
+ self.assertEqual(self.configured(self.baseline("LEV"), {}),
37
+ ("file-model", "file-model", "legacy"))
38
+
39
+ def test_legacy_process_overrides_canonical_file(self):
40
+ self.assertEqual(self.configured(self.baseline("ANNO"), {"LEV_GENERATE_MODEL": "process"})[1],
41
+ "process")
42
+
43
+ def test_canonical_process_overrides_legacy_file(self):
44
+ self.assertEqual(self.configured(self.baseline("LEV"), {"ANNO_GENERATE_MODEL": "process"})[1],
45
+ "process")
46
+
47
+ def test_canonical_wins_within_each_layer(self):
48
+ file = {**self.baseline("LEV"), "ANNO_GENERATE_MODEL": "canonical-file"}
49
+ self.assertEqual(self.configured(file, {})[1], "canonical-file")
50
+ self.assertEqual(self.configured(file, {"LEV_GENERATE_MODEL": "old", "ANNO_GENERATE_MODEL": "new"})[1], "new")
51
+
52
+ def test_explicit_arguments_override_both_layers(self):
53
+ file = {**self.baseline("LEV"), "ANNO_GENERATE_PROFILE": "invalid"}
54
+ self.assertEqual(self.configured(file, {"LEV_GENERATE_PROFILE": "also-invalid"},
55
+ generation_profile="qwen_vllm")[2], "qwen_vllm")
56
+
57
+ def test_empty_canonical_value_does_not_restore_legacy_secret(self):
58
+ with self.assertRaisesRegex(ValueError, "ANNO_GENERATE_AUTH_TOKEN"):
59
+ self.configured(self.baseline("LEV"), {"ANNO_GENERATE_AUTH_TOKEN": ""})
60
+ self.assertEqual(env_value("ANNO_SERVICE_TOKEN", environ={"LEV_SERVICE_TOKEN": "old"}), "old")
61
+ self.assertEqual(env_value("ANNO_SERVICE_TOKEN", environ={"LEV_SERVICE_TOKEN": "old", "ANNO_SERVICE_TOKEN": "new"}), "new")
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()
@@ -0,0 +1,251 @@
1
+ """Offline examples through real normalization, HTTP adapters and runtime."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ import unittest
6
+
7
+ import httpx
8
+
9
+
10
+ from annoscript._data import thaw
11
+ from annoscript._test_evaluation_http import http_backends, llm_calls, decision_calls
12
+
13
+ from annoscript.backends import InferenceContext, InferenceError, DecisionAPIBackend
14
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
15
+ from annoscript.backends.base import normalize_call, validate_response, BatchResult
16
+ from annoscript.backends._bounded_grammar import grammar
17
+ from annoscript.compiler import compile_program
18
+ from annoscript.ir import Program
19
+ from annoscript.anno import Example, ExtractResponse, ChoiceResponse
20
+ from annoscript.plan import ExecutionPlan
21
+ from annoscript.plan_lowering import lower_to_plan
22
+ from annoscript.runtime import AnnoRuntime, RunInput
23
+ from annoscript.test_examples import REVIEW, LAUNCH, captured, constructed, different_examples
24
+ from annoscript.test_generation_policy import policy
25
+ from annoscript.test_llm_batch import completion
26
+
27
+
28
+ def call(operation, answer, *, identity="q", examples=True, **options):
29
+ arguments = {"choice": ("分类", {"正面": "积极", "中性": "中立"}),
30
+ "noul": ("是事件吗",), "score": ("评分", ["评价", "资讯"]),
31
+ "generate": ("标题",), "extract": ("地点",),
32
+ "generate_list": ("标签",)}[operation]
33
+ limits = ({"max_length": 80} if operation == "generate" else
34
+ {"max_items": 2, "max_length_per_item": 80} if operation in ("extract", "generate_list") else {})
35
+ if operation == "extract":
36
+ limits["source_field"] = "text"
37
+ if examples:
38
+ limits["examples"] = [Example(LAUNCH, answer)]
39
+ return normalize_call(identity, operation, arguments, {**limits, **options}, InferenceContext(REVIEW))
40
+
41
+
42
+ class ExampleValidationTests(unittest.TestCase):
43
+ def test_all_six_operations_and_advisory_lengths(self):
44
+ for operation, answer in [("choice", "中性"), ("noul", True), ("score", 1),
45
+ ("generate", LAUNCH["text"]), ("generate_list", ["资讯"]),
46
+ ("extract", ["广州"])]:
47
+ with self.subTest(operation=operation):
48
+ result = call(operation, answer)
49
+ self.assertEqual(thaw(result.arguments)["examples"], [{"state": LAUNCH, "answer": answer}])
50
+ call("generate", LAUNCH["text"], max_length=1)
51
+ call("generate_list", ["资讯"], max_length_per_item=1)
52
+ call("extract", ["广州"], max_length_per_item=1)
53
+
54
+ def test_invalid_answers_shapes_and_source_bindings(self):
55
+ cases = [
56
+ ("choice", "不存在", {}), ("choice", {"choice": "中性", "confidence": .95}, {}),
57
+ ("noul", .95, {}), ("score", True, {}), ("score", 2, {}), ("score", -1, {}),
58
+ ("generate", None, {}), ("generate", "", {"min_length": 1}),
59
+ ("generate_list", [None], {}), ("generate_list", [""], {"min_length_per_item": 1}),
60
+ ("generate_list", ["资讯"], {"max_items": 0}),
61
+ ("extract", [], {"min_items": 1}), ("extract", [""], {}),
62
+ ("extract", ["条目"], {}), ("extract", ["广州上市"], {}),
63
+ ("extract", ["广州"], {"source_field": "missing"}),
64
+ ]
65
+ for operation, answer, options in cases:
66
+ with self.subTest(operation=operation, answer=answer), self.assertRaises(ValueError):
67
+ call(operation, answer, **options)
68
+ for examples in ({}, (), [{"state": LAUNCH}], [{"state": LAUNCH, "answer": [], "criteria": {}}],
69
+ [{"state": None, "answer": []}]):
70
+ with self.subTest(examples=examples), self.assertRaises(ValueError):
71
+ normalize_call("q", "extract", ("地点",),
72
+ {"examples": examples, "source_field": "text",
73
+ "max_items": 2, "max_length_per_item": 80}, InferenceContext(REVIEW))
74
+ c = normalize_call("q", "extract", ("地点",),
75
+ {"examples": [Example(LAUNCH["text"], ["广州"])],
76
+ "max_items": 2, "max_length_per_item": 80}, InferenceContext(REVIEW["text"]))
77
+ self.assertEqual(c.extraction_source, REVIEW["text"])
78
+
79
+ def test_examples_cannot_expand_current_source_or_reuse_prepared_request(self):
80
+ a = call("extract", ["广州"])
81
+ b = call("extract", [])
82
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
83
+ profile="qwen_vllm", extract_mode="substring_chars")
84
+ self.assertEqual(grammar([a], extract_mode="substring_chars"),
85
+ grammar([b], extract_mode="substring_chars"))
86
+ with self.assertRaises(InferenceError):
87
+ validate_response(a, ExtractResponse(["广州"]))
88
+ validate_response(a, ExtractResponse(["条目"]))
89
+ context = InferenceContext(REVIEW)
90
+ pa = backend._prepare(context, [a], 32)
91
+ pb = backend._prepare(context, [b], 32)
92
+ self.assertEqual(pa.binding, backend._prepare(context, [a], 32).binding)
93
+ self.assertNotEqual(pa.binding, pb.binding)
94
+ self.assertNotEqual(pa.body, pb.body)
95
+ self.assertNotIn("广", pa.payload["structured_outputs"]["grammar"])
96
+
97
+
98
+ class ExampleBackendTests(unittest.IsolatedAsyncioTestCase):
99
+
100
+
101
+ async def test_missing_and_empty_examples_preserve_wire_for_every_operation(self):
102
+ operations = [("choice", "中性"), ("noul", True), ("score", 1),
103
+ ("generate", "标题"), ("generate_list", []), ("extract", [])]
104
+ sent = []
105
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture",
106
+ transport=httpx.MockTransport(lambda request: (
107
+ sent.append(json.loads(request.content)) or httpx.Response(200, json={"answers": {}}))))
108
+ llm = LLMAPIBackend("http://offline.invalid", "fixture", "fixture")
109
+ context = InferenceContext(REVIEW, "共用规则")
110
+ try:
111
+ for operation, answer in operations:
112
+ original = call(operation, answer, examples=False)
113
+ for empty in (None, []):
114
+ empty_call = normalize_call("q", operation, (), {**thaw(original.arguments), "examples": empty}, context)
115
+ self.assertEqual(empty_call, original)
116
+ if operation in ("generate", "generate_list", "extract"):
117
+ self.assertEqual(llm._single_payload(context, original, 100),
118
+ llm._single_payload(context, empty_call, 100))
119
+ else:
120
+ for c in (original, empty_call):
121
+ with self.assertRaises(InferenceError):
122
+ await backend.infer_batch(context, [c])
123
+ self.assertEqual(sent[-2], sent[-1])
124
+ finally:
125
+ await backend.aclose()
126
+ await llm.aclose()
127
+
128
+ async def test_jev_criteria_examples_mixed_with_plain_question(self):
129
+ sent = []
130
+ def handle(request):
131
+ body = json.loads(request.content)
132
+ sent.append(body)
133
+ answers = {}
134
+ for identity, q in body["questions"].items():
135
+ if q["type"] == "noul":
136
+ answer = {"noul": .9}
137
+ elif q["type"] == "score":
138
+ answer = {"score": 1, "confidence": .9, "legend": {"0": "评价", "1": "资讯"},
139
+ "probabilities": {"0": .1, "1": .9}}
140
+ else:
141
+ answer = {"choice": "中性", "confidence": .9, "probabilities": {"正面": .1, "中性": .9}}
142
+ answers[identity] = {"type": q["type"], **answer}
143
+ return httpx.Response(200, json={"answers": answers})
144
+ backend = DecisionAPIBackend("http://offline.invalid", "fixture", "fixture", transport=httpx.MockTransport(handle))
145
+ calls = [call("choice", "中性", identity="choice"), call("noul", True, identity="noul"),
146
+ call("score", 1, identity="score"), call("noul", True, identity="plain", examples=False)]
147
+ try:
148
+ await backend.infer_batch(InferenceContext(REVIEW, "共用规则"), calls)
149
+ finally:
150
+ await backend.aclose()
151
+ body = sent[0]
152
+ self.assertEqual(body["state"], REVIEW)
153
+ self.assertNotIn("examples", body)
154
+ for c in calls:
155
+ self.assertEqual(body["questions"][c.id]["instructions"], c.arguments["instructions"])
156
+ self.assertEqual(body["questions"]["choice"]["criteria"],
157
+ {"正面": "积极", "中性": {"what": "中立", "examples": [LAUNCH]}})
158
+ self.assertEqual(body["questions"]["noul"]["criteria"], {"true": {"examples": [LAUNCH]}})
159
+ self.assertEqual(body["questions"]["score"]["criteria"],
160
+ ["评价", {"what": "资讯", "examples": [LAUNCH]}])
161
+ self.assertNotIn("criteria", body["questions"]["plain"])
162
+
163
+ async def test_qwen_sharing_single_batch_and_repair_round_trip(self):
164
+ calls = [call("extract", ["广州"], identity="extract", max_num_tokens=32),
165
+ call("generate", LAUNCH["text"], identity="generate", max_num_tokens=32),
166
+ call("generate_list", ["资讯"], identity="list", max_num_tokens=32)]
167
+ context = InferenceContext(REVIEW)
168
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
169
+ bodies = [backend._batch_payload(context, calls, 256)]
170
+ for c in calls:
171
+ bodies.extend([backend._single_payload(context, c, 32),
172
+ backend._single_payload(context, c, 32, "格式错误")])
173
+ for body in bodies:
174
+ self.assertEqual(json.loads(body["messages"][-1]["content"]), REVIEW)
175
+ self.assertEqual(body["messages"][0]["content"].count(
176
+ '输入:' + json.dumps(LAUNCH, ensure_ascii=False, separators=(",", ":"))), 1)
177
+ batched, decoded = llm_calls(body)
178
+ expected = calls if batched else [next(c for c in calls if c.id == decoded[0][1].id)]
179
+ for (_, reconstructed), original in zip(decoded, expected):
180
+ a, b = thaw(reconstructed.arguments), thaw(original.arguments)
181
+ a.pop("max_num_tokens")
182
+ b.pop("max_num_tokens")
183
+ self.assertEqual(a, b)
184
+ self.assertEqual(thaw(calls[0].arguments)["examples"], [{"state": LAUNCH, "answer": ["广州"]}])
185
+
186
+ async def test_examples_affect_split_bytes_context_checks_and_preserve_output_budget(self):
187
+ context = InferenceContext(REVIEW)
188
+ calls = [call("generate", LAUNCH["text"], identity=str(i), max_num_tokens=32) for i in range(3)]
189
+ plain = [call("generate", LAUNCH["text"], identity=str(i), examples=False, max_num_tokens=32) for i in range(3)]
190
+ def count(body):
191
+ return 100 * len(llm_calls(body)[1]) if "\n\nExample\n" in body["messages"][0]["content"] else 10
192
+ config = policy()
193
+ config.update(context_window=500, default_max_tokens=32)
194
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
195
+ profile="qwen_vllm", request_policy=config, _token_counter=count)
196
+ options = dict(max_calls=8, max_input_bytes=100000, max_output_tokens=400, default_tokens=32)
197
+ self.assertEqual(len(backend.plan_batch(context, plain, **options).calls), 3)
198
+ plan = backend.plan_batch(context, calls, **options)
199
+ self.assertEqual(len(plan.calls), 2)
200
+ self.assertEqual(plan.max_tokens, 192)
201
+ with self.subTest("byte limit"):
202
+ bounded = backend.plan_batch(context, calls, **{**options, "max_input_bytes": plan.input_bytes - 1})
203
+ self.assertIsNone(bounded)
204
+ sent = []
205
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
206
+ profile="qwen_vllm", request_policy=config,
207
+ _token_counter=lambda b: 480 if "\n\nExample\n" in b["messages"][0]["content"] else 10,
208
+ transport=httpx.MockTransport(lambda r: sent.append(r) or completion("标题")))
209
+ try:
210
+ with self.assertRaises(InferenceError) as caught:
211
+ await backend.infer(context, calls[0])
212
+ self.assertEqual(caught.exception.code, "context_length_exceeded")
213
+ self.assertEqual(sent, [])
214
+ await backend.infer(context, plain[0])
215
+ self.assertEqual(json.loads(sent[0].content)["max_tokens"], 32)
216
+ finally:
217
+ await backend.aclose()
218
+
219
+ async def test_runtime_snapshots_modes_and_different_examples_never_merge(self):
220
+ class Decision:
221
+ def __init__(self):
222
+ self.calls = []
223
+ async def infer_batch(self, context, calls):
224
+ self.calls.extend(calls)
225
+ return BatchResult({c.id: ChoiceResponse(c.arguments["examples"][0]["answer"], .9,
226
+ {"正面": .5, "中性": .5}) for c in calls})
227
+ async def aclose(self):
228
+ pass
229
+ class LLM:
230
+ async def infer(self, context, call):
231
+ from annoscript.backends import InferenceOutput
232
+ self.examples = thaw(call.arguments)["examples"]
233
+ return InferenceOutput(ExtractResponse([]))
234
+ async def aclose(self):
235
+ pass
236
+ for optimize in (False, True):
237
+ for mode in ("strict", "eager"):
238
+ decision, llm = Decision(), LLM()
239
+ async with AnnoRuntime(decision, llm) as runtime:
240
+ for function in (captured, constructed, different_examples):
241
+ program = Program.from_json(compile_program(function, optimize=optimize).to_json())
242
+ plan = lower_to_plan(program)
243
+ plan = ExecutionPlan.from_json(plan.to_json())
244
+ result, = [r async for r in await runtime.submit(plan, [RunInput(0, REVIEW)], mode=mode)]
245
+ self.assertEqual(result.status, "success", result.error)
246
+ if function is different_examples:
247
+ self.assertEqual(result.value, ["正面", "中性", "正面"])
248
+ self.assertEqual(len(decision.calls), 2)
249
+ else:
250
+ self.assertEqual(llm.examples[0]["answer"], ["广州"])
251
+ self.assertEqual(llm.examples[0]["state"]["text"], LAUNCH["text"])