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,149 @@
|
|
|
1
|
+
"""Client-side example snapshots and compilation; no backend dependencies."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from annoscript_examples.fixtures import REVIEW_INPUT as REVIEW, LAUNCH_INPUT as LAUNCH
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from annoscript.compiler import compile_program
|
|
13
|
+
from annoscript.ir import Program
|
|
14
|
+
from annoscript.anno import Example, ExtractResponse, GenerateResponse, GenerateListResponse
|
|
15
|
+
from annoscript._test_ir_runtime import evaluate
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
LOCATION = Example(LAUNCH, ["广州"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def captured(anno):
|
|
22
|
+
return anno.extract("地点", source_field="text", max_items=2,
|
|
23
|
+
max_length_per_item=80, examples=[LOCATION]).items
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def constructed(anno):
|
|
27
|
+
state = {"text": "示例在广州发布"}
|
|
28
|
+
answer = ["广州"]
|
|
29
|
+
example = Example(state=state, answer=answer)
|
|
30
|
+
state["text"] = "示例条目"
|
|
31
|
+
answer[0] = "条目"
|
|
32
|
+
return anno.extract("地点", source_field="text", max_items=2,
|
|
33
|
+
max_length_per_item=80, examples=[example]).items
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def different_examples(anno):
|
|
37
|
+
# Deliberately vary only the answer to check identity, not business accuracy.
|
|
38
|
+
first = anno.choice("情感", {"正面": "积极", "中性": "中立"},
|
|
39
|
+
examples=[Example(REVIEW, "正面")]).choice
|
|
40
|
+
second = anno.choice("情感", {"正面": "积极", "中性": "中立"},
|
|
41
|
+
examples=[Example(REVIEW, "中性")]).choice
|
|
42
|
+
third = anno.choice("情感", {"正面": "积极", "中性": "中立"},
|
|
43
|
+
examples=[Example(REVIEW, "正面")]).choice
|
|
44
|
+
return [first, second, third]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def named_generations(anno):
|
|
48
|
+
title = anno.generate("标题", max_length=60, field_name="event_title").text
|
|
49
|
+
dimensions = anno.generate_list("维度", max_items=8, max_length_per_item=24,
|
|
50
|
+
field_name="tags").items
|
|
51
|
+
locations = anno.extract("地点", source_field="text", max_items=8,
|
|
52
|
+
max_length_per_item=64, field_name="locations").items
|
|
53
|
+
return [title, dimensions, locations]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def nullable_example(anno):
|
|
57
|
+
value = anno.generate("缺依据为 null", nullable=True, min_length=1, max_length=24,
|
|
58
|
+
examples=[Example(state="示例正文", answer=None)]).text
|
|
59
|
+
if value is not None:
|
|
60
|
+
return value.strip()
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def requirements_for_all_operations(anno):
|
|
65
|
+
requirements = ["仅依据正文。", "保留原意。"]
|
|
66
|
+
a = anno.noul("判断", requirements=requirements).noul
|
|
67
|
+
b = anno.choice("选择", {"yes": "是"}, requirements=requirements).choice
|
|
68
|
+
c = anno.score("评分", ["低", "高"], requirements=requirements).score
|
|
69
|
+
d = anno.generate("标签", max_length=24, requirements=requirements).text
|
|
70
|
+
e = anno.generate_list("维度", max_items=2, max_length_per_item=24,
|
|
71
|
+
requirements=requirements).items
|
|
72
|
+
f = anno.extract("地点", source_field="text", max_items=2, max_length_per_item=24,
|
|
73
|
+
requirements=requirements).items
|
|
74
|
+
return [a, b, c, d, e, f]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ExampleClientTests(unittest.TestCase):
|
|
78
|
+
def test_requirements_survive_client_ir_for_all_operations(self):
|
|
79
|
+
from annoscript.anno import NoulResponse, ChoiceResponse, ScoreResponse
|
|
80
|
+
|
|
81
|
+
class Probe:
|
|
82
|
+
def __init__(self):
|
|
83
|
+
self.calls = []
|
|
84
|
+
|
|
85
|
+
def record(self, operation, options, response):
|
|
86
|
+
self.calls.append(operation)
|
|
87
|
+
assert options["requirements"] == ["仅依据正文。", "保留原意。"]
|
|
88
|
+
return response
|
|
89
|
+
|
|
90
|
+
def noul(self, instructions, **options):
|
|
91
|
+
return self.record("noul", options, NoulResponse(.8))
|
|
92
|
+
|
|
93
|
+
def choice(self, instructions, criteria, **options):
|
|
94
|
+
return self.record("choice", options, ChoiceResponse("yes", .9, {"yes": 1.}))
|
|
95
|
+
|
|
96
|
+
def score(self, instructions, criteria, **options):
|
|
97
|
+
return self.record("score", options, ScoreResponse({0: 0., 1: 1.}, 1., dict(enumerate(criteria)), .9))
|
|
98
|
+
|
|
99
|
+
def generate(self, instructions, **options):
|
|
100
|
+
return self.record("generate", options, GenerateResponse("标签"))
|
|
101
|
+
|
|
102
|
+
def generate_list(self, instructions, **options):
|
|
103
|
+
return self.record("generate_list", options, GenerateListResponse(["维度"]))
|
|
104
|
+
|
|
105
|
+
def extract(self, instructions, **options):
|
|
106
|
+
return self.record("extract", options, ExtractResponse(["广州"]))
|
|
107
|
+
|
|
108
|
+
for optimize in (False, True):
|
|
109
|
+
probe = Probe()
|
|
110
|
+
program = Program.from_json(compile_program(requirements_for_all_operations, optimize=optimize).to_json())
|
|
111
|
+
self.assertEqual(evaluate(program.to_dict(), probe), [.8, "yes", 1., "标签", ["维度"], ["广州"]])
|
|
112
|
+
self.assertEqual(probe.calls, ["noul", "choice", "score", "generate", "generate_list", "extract"])
|
|
113
|
+
|
|
114
|
+
def test_nullable_example_and_none_survive_client_ir(self):
|
|
115
|
+
class Probe:
|
|
116
|
+
state = "正文"
|
|
117
|
+
|
|
118
|
+
def generate(self, instructions, **options):
|
|
119
|
+
assert options["nullable"] is True
|
|
120
|
+
example = options["examples"][0]
|
|
121
|
+
example = example.to_dict() if type(example) is Example else example
|
|
122
|
+
assert example["answer"] is None
|
|
123
|
+
return GenerateResponse(None)
|
|
124
|
+
|
|
125
|
+
for optimize in (False, True):
|
|
126
|
+
program = Program.from_json(compile_program(nullable_example, optimize=optimize).to_json())
|
|
127
|
+
self.assertIsNone(evaluate(program.to_dict(), Probe()))
|
|
128
|
+
|
|
129
|
+
def test_field_names_survive_client_ir_roundtrip(self):
|
|
130
|
+
class Probe:
|
|
131
|
+
state = {"text": "广州"}
|
|
132
|
+
|
|
133
|
+
def generate(self, instructions, **options):
|
|
134
|
+
return GenerateResponse(options["field_name"])
|
|
135
|
+
|
|
136
|
+
def generate_list(self, instructions, **options):
|
|
137
|
+
return GenerateListResponse([options["field_name"]])
|
|
138
|
+
|
|
139
|
+
def extract(self, instructions, **options):
|
|
140
|
+
return ExtractResponse([options["field_name"]])
|
|
141
|
+
|
|
142
|
+
for optimize in (False, True):
|
|
143
|
+
program = Program.from_json(compile_program(named_generations, optimize=optimize).to_json())
|
|
144
|
+
self.assertEqual(evaluate(program.to_dict(), Probe()),
|
|
145
|
+
["event_title", ["tags"], ["locations"]])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
unittest.main()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Public task names through the real wire, planner, runtime and durable jobs."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from annoscript._data import thaw
|
|
13
|
+
from annoscript.backends import InferenceContext, LLMAPIBackend, InferenceError
|
|
14
|
+
from annoscript.backends.base import normalize_call
|
|
15
|
+
from annoscript.compiler import compile_program
|
|
16
|
+
from annoscript.ir import Program
|
|
17
|
+
from annoscript.plan import ExecutionPlan
|
|
18
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
19
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
20
|
+
from annoscript.server.releases import Releases
|
|
21
|
+
from annoscript.server.store import Store
|
|
22
|
+
from annoscript.server.worker import Worker
|
|
23
|
+
from annoscript.test_jobs_server import configurations, payload
|
|
24
|
+
from annoscript.test_runtime import FakeBackend
|
|
25
|
+
from annoscript._test_evaluation_http import FixtureLLMBackend, llm_calls
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def named_pair(anno):
|
|
29
|
+
first = anno.generate("same", max_length=10, max_num_tokens=32, field_name="first").text
|
|
30
|
+
second = anno.generate("same", max_length=10, max_num_tokens=32, field_name="second").text
|
|
31
|
+
again = anno.generate("same", max_length=10, max_num_tokens=32, field_name="first").text
|
|
32
|
+
return [first, second, again]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def completion(content):
|
|
36
|
+
return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}],
|
|
37
|
+
"usage": {"prompt_tokens": 7, "completion_tokens": 2}})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def named_handler(sent, *, repair=False):
|
|
41
|
+
def handle(request):
|
|
42
|
+
body = json.loads(request.content)
|
|
43
|
+
sent.append(body)
|
|
44
|
+
batched, calls = llm_calls(body)
|
|
45
|
+
if batched:
|
|
46
|
+
answer = {call.arguments["field_name"]: call.arguments["field_name"] for _, call in calls}
|
|
47
|
+
if repair:
|
|
48
|
+
answer.pop("second")
|
|
49
|
+
return completion(json.dumps({"answers": answer}))
|
|
50
|
+
return completion(calls[0][1].arguments["field_name"])
|
|
51
|
+
return handle
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FieldNameTests(unittest.IsolatedAsyncioTestCase):
|
|
55
|
+
def make_call(self, operation="generate", name=None, identity="run1-q1", context=None, **options):
|
|
56
|
+
context = context or InferenceContext({"text": 'q1 q10 甲"\\\n😀'})
|
|
57
|
+
defaults = {"max_length": 10} if operation == "generate" else {"max_items": 2, "max_length_per_item": 10}
|
|
58
|
+
if operation == "extract":
|
|
59
|
+
defaults["source_field"] = "text"
|
|
60
|
+
if name is not None:
|
|
61
|
+
defaults["field_name"] = name
|
|
62
|
+
return normalize_call(identity, operation, ("保留正文中的 q1 与 q10",), {**defaults, **options}, context)
|
|
63
|
+
|
|
64
|
+
async def test_none_and_omitted_preserve_exact_payload_and_binding(self):
|
|
65
|
+
ctx = InferenceContext({"text": "甲"})
|
|
66
|
+
backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
|
|
67
|
+
try:
|
|
68
|
+
for op in ("generate", "generate_list", "extract"):
|
|
69
|
+
absent = self.make_call(op, context=ctx)
|
|
70
|
+
explicit = self.make_call(op, context=ctx, field_name=None)
|
|
71
|
+
self.assertEqual(absent, explicit)
|
|
72
|
+
self.assertNotIn("field_name", absent.arguments)
|
|
73
|
+
a, b = backend._prepare(ctx, [absent], 128), backend._prepare(ctx, [explicit], 128)
|
|
74
|
+
self.assertEqual((a.binding, a.body, a.payload), (b.binding, b.body, b.payload))
|
|
75
|
+
other = self.make_call(op, identity="run1-q2", context=ctx)
|
|
76
|
+
self.assertEqual(backend._batch_payload(ctx, [absent, other], 256),
|
|
77
|
+
backend._batch_payload(ctx, [explicit, other], 256))
|
|
78
|
+
finally:
|
|
79
|
+
await backend.aclose()
|
|
80
|
+
|
|
81
|
+
async def test_validation_and_collisions_never_send_http(self):
|
|
82
|
+
sent = []
|
|
83
|
+
ctx = InferenceContext({"text": "甲"})
|
|
84
|
+
backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture",
|
|
85
|
+
transport=httpx.MockTransport(lambda r: sent.append(r)))
|
|
86
|
+
try:
|
|
87
|
+
for op in ("generate", "generate_list", "extract"):
|
|
88
|
+
for value in ("", " \t\n", False, 1, [], {}):
|
|
89
|
+
with self.subTest(op=op, value=value), self.assertRaisesRegex(ValueError, "field_name"):
|
|
90
|
+
self.make_call(op, context=ctx, field_name=value)
|
|
91
|
+
for names in (("same", "same"), ("q2", None)):
|
|
92
|
+
calls = [self.make_call(name=n, identity=str(i), context=ctx) for i, n in enumerate(names)]
|
|
93
|
+
with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
|
|
94
|
+
await backend.infer_batch(ctx, calls, max_tokens=128)
|
|
95
|
+
with self.assertRaisesRegex(ValueError, "duplicate model-visible"):
|
|
96
|
+
backend.plan_batch(ctx, calls, max_calls=8, max_input_bytes=100000,
|
|
97
|
+
max_output_tokens=4096, default_tokens=1024)
|
|
98
|
+
self.assertEqual(sent, [])
|
|
99
|
+
finally:
|
|
100
|
+
await backend.aclose()
|
|
101
|
+
|
|
102
|
+
async def test_mixed_names_examples_grammar_and_reordered_answers(self):
|
|
103
|
+
ctx = InferenceContext({"text": 'q1 q10 甲"\\\n😀'})
|
|
104
|
+
strange = ' 地点"\\\n😀 '
|
|
105
|
+
calls = [self.make_call(name="q10", context=ctx),
|
|
106
|
+
self.make_call("extract", name=strange, identity="run1-q2", context=ctx,
|
|
107
|
+
examples=[{"state": {"text": "广州"}, "answer": ["广州"]}]),
|
|
108
|
+
self.make_call("generate_list", identity="run1-q3", context=ctx)]
|
|
109
|
+
body = {"answers": {"q3": [], strange: ["甲"], "q10": "标题"}}
|
|
110
|
+
for profile in ("legacy", "qwen_vllm"):
|
|
111
|
+
sent, events = [], []
|
|
112
|
+
def handle(request):
|
|
113
|
+
sent.append(json.loads(request.content))
|
|
114
|
+
return completion(json.dumps(body, ensure_ascii=False))
|
|
115
|
+
backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile,
|
|
116
|
+
transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
|
|
117
|
+
try:
|
|
118
|
+
result = await backend.infer_batch(ctx, calls, max_tokens=256)
|
|
119
|
+
self.assertEqual(result.errors, {})
|
|
120
|
+
self.assertEqual(result.responses[calls[0].id].text, "标题")
|
|
121
|
+
self.assertEqual(result.responses[calls[1].id].items, ["甲"])
|
|
122
|
+
self.assertEqual(result.responses[calls[2].id].items, [])
|
|
123
|
+
prompt = sent[0]["messages"][0]["content"]
|
|
124
|
+
self.assertIn('保留正文中的 q1 与 q10', prompt)
|
|
125
|
+
self.assertIn('任务 ' + json.dumps(strange, ensure_ascii=False), prompt)
|
|
126
|
+
self.assertIn('示例输出:' + json.dumps({"answers": {strange: ["广州"]}}, ensure_ascii=False, separators=(",", ":")), prompt)
|
|
127
|
+
self.assertEqual(json.loads(sent[0]["messages"][1]["content"]), thaw(ctx.state))
|
|
128
|
+
self.assertTrue(any(e.get("wire_call_ids") == {"q10": calls[0].id, strange: calls[1].id, "q3": calls[2].id}
|
|
129
|
+
for e in events))
|
|
130
|
+
if profile == "qwen_vllm":
|
|
131
|
+
root = sent[0]["structured_outputs"]["grammar"].splitlines()[0]
|
|
132
|
+
self.assertIn("q10", root)
|
|
133
|
+
self.assertNotIn("run1", root)
|
|
134
|
+
finally:
|
|
135
|
+
await backend.aclose()
|
|
136
|
+
|
|
137
|
+
async def test_single_and_repair_only_rename_task_identity(self):
|
|
138
|
+
ctx = InferenceContext({"text": "甲"})
|
|
139
|
+
for op, text in (("generate", "标题"), ("extract", '{"items":["甲"]}'),
|
|
140
|
+
("generate_list", '{"items":[]}')):
|
|
141
|
+
sent, events = [], []
|
|
142
|
+
def handle(request):
|
|
143
|
+
sent.append(json.loads(request.content))
|
|
144
|
+
return completion(text)
|
|
145
|
+
backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
|
|
146
|
+
transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
|
|
147
|
+
try:
|
|
148
|
+
plain = self.make_call(op, context=ctx, max_num_tokens=64)
|
|
149
|
+
named = self.make_call(op, name="summary", context=ctx, max_num_tokens=64)
|
|
150
|
+
for feedback in (None, "invalid answer"):
|
|
151
|
+
expected = backend._single_payload(ctx, plain, 64, feedback)
|
|
152
|
+
await backend.infer(ctx, named, repair_feedback=feedback)
|
|
153
|
+
actual = sent[-1]
|
|
154
|
+
actual["messages"][0]["content"] = actual["messages"][0]["content"].replace('任务 "summary"', '任务 "run1-q1"')
|
|
155
|
+
self.assertEqual(actual, expected)
|
|
156
|
+
self.assertTrue(any(e.get("wire_call_ids") == {"summary": named.id} for e in events))
|
|
157
|
+
finally:
|
|
158
|
+
await backend.aclose()
|
|
159
|
+
|
|
160
|
+
async def test_names_bind_preparation_and_remain_stable_in_split_subsets(self):
|
|
161
|
+
ctx = InferenceContext({"text": "甲"})
|
|
162
|
+
backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm")
|
|
163
|
+
try:
|
|
164
|
+
calls = [self.make_call(name=n, identity=str(i), context=ctx) for i, n in enumerate(("first", "second", "third"))]
|
|
165
|
+
first = backend._prepare(ctx, calls[:2], 128, batch=True)
|
|
166
|
+
second = backend._prepare(ctx, calls[1:], 128, batch=True)
|
|
167
|
+
self.assertEqual(dict(first.evidence["wire_call_ids"]), {"first": "0", "second": "1"})
|
|
168
|
+
self.assertEqual(dict(second.evidence["wire_call_ids"]), {"second": "1", "third": "2"})
|
|
169
|
+
changed = replace(calls[0], arguments={**calls[0].arguments, "field_name": "different"})
|
|
170
|
+
self.assertNotEqual(first.binding, backend._prepare(ctx, [changed, calls[1]], 128, batch=True).binding)
|
|
171
|
+
finally:
|
|
172
|
+
await backend.aclose()
|
|
173
|
+
|
|
174
|
+
async def test_ir_runtime_reuse_and_repair_in_all_modes(self):
|
|
175
|
+
for optimize in (False, True):
|
|
176
|
+
program = Program.from_json(compile_program(named_pair, optimize=optimize).to_json())
|
|
177
|
+
plan = ExecutionPlan.from_json(lower_to_plan(program).to_json())
|
|
178
|
+
for mode in ("strict", "eager", "speculative"):
|
|
179
|
+
for repair in (False, True):
|
|
180
|
+
sent = []
|
|
181
|
+
backend = FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
|
|
182
|
+
transport=httpx.MockTransport(named_handler(sent, repair=repair)))
|
|
183
|
+
async with AnnoRuntime(FakeBackend(), backend, trace=True, max_llm_batch_calls=16,
|
|
184
|
+
max_llm_batch_output_tokens=4096) as runtime:
|
|
185
|
+
results = [r async for r in await runtime.submit(plan, [RunInput(0, "甲")], mode=mode)]
|
|
186
|
+
self.assertEqual(results[0].status, "success", results[0])
|
|
187
|
+
self.assertEqual(results[0].value, ["first", "second", "first"])
|
|
188
|
+
self.assertEqual(sum(len(llm_calls(b)[1]) for b in sent), 3 if repair and mode != "strict" else 2)
|
|
189
|
+
self.assertTrue(all(b["max_tokens"] == (192 if llm_calls(b)[0] else 32) for b in sent))
|
|
190
|
+
if repair and mode != "strict":
|
|
191
|
+
self.assertEqual(len(sent), 2)
|
|
192
|
+
self.assertIn('任务 "second"', sent[-1]["messages"][0]["content"])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class DurableFieldNameTests(unittest.TestCase):
|
|
196
|
+
def test_names_roundtrip_through_worker_and_collect_does_not_infer(self):
|
|
197
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
198
|
+
store = Store(Path(directory) / "jobs.sqlite")
|
|
199
|
+
releases = Releases(configurations(), "test")
|
|
200
|
+
sent = []
|
|
201
|
+
def factory(_):
|
|
202
|
+
return FakeBackend(), FixtureLLMBackend("http://offline.invalid", "fixture", "fixture", profile="qwen_vllm",
|
|
203
|
+
transport=httpx.MockTransport(named_handler(sent)))
|
|
204
|
+
request = payload(("甲", "乙"), mode="speculative", program=named_pair)
|
|
205
|
+
job = store.submit("alice", "named", request, releases)
|
|
206
|
+
worker = Worker(store, releases, backend_factory=factory)
|
|
207
|
+
async def run():
|
|
208
|
+
while (claim := store.claim("worker", releases)) is not None:
|
|
209
|
+
await worker.execute(claim)
|
|
210
|
+
asyncio.run(run())
|
|
211
|
+
before = len(sent)
|
|
212
|
+
for _ in range(2):
|
|
213
|
+
page = store.results("alice", job["job_id"])
|
|
214
|
+
self.assertEqual([r["value"] for r in page["results"]], [["first", "second", "first"]] * 2)
|
|
215
|
+
self.assertEqual(store.submit("alice", "named", request, releases)["job_id"], job["job_id"])
|
|
216
|
+
self.assertIsNone(store.claim("worker", releases))
|
|
217
|
+
self.assertEqual(len(sent), before)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
unittest.main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
import io
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from annoscript import ir
|
|
12
|
+
from annoscript.compiler import compile_program
|
|
13
|
+
from annoscript.formatter import format_ir
|
|
14
|
+
from annoscript.inspect import main
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def example_program():
|
|
21
|
+
span = ir.SourceSpan("missing.source.module", "annotate", 1, 1, 12, 1)
|
|
22
|
+
source = ir.Source(span, (ir.SourceSpan("caller", "entry", 5, 1, 5, 20),))
|
|
23
|
+
result = ir.Var("decision")
|
|
24
|
+
body = (
|
|
25
|
+
ir.Assign("text", ir.GetItem(ir.State(), ir.Literal("text")), id="s1", source=source),
|
|
26
|
+
ir.Infer("decision", "choice", (ir.Literal("判断事件"), ir.DictExpr((
|
|
27
|
+
(ir.Literal("yes"), ir.Literal("事件")), (ir.Literal("no"), ir.Literal("非事件")),
|
|
28
|
+
))), (), id="s2", source=source),
|
|
29
|
+
ir.If(ir.Compare(ir.Field(result, "choice"), ("eq",), (ir.Literal("yes"),)), (
|
|
30
|
+
ir.Constrain(ir.Compare(ir.Intrinsic("len", (ir.Var("text"),)), ("gt",), (ir.Literal(0),)),
|
|
31
|
+
ir.Literal("必须有正文"), id="s3", source=source),
|
|
32
|
+
ir.Return(ir.Var("text"), id="s4", source=source),
|
|
33
|
+
), (
|
|
34
|
+
ir.Return(ir.Literal(None), id="s5", source=source),
|
|
35
|
+
), id="s6", source=source),
|
|
36
|
+
)
|
|
37
|
+
return ir.Program("demo", ir.ProgramConfig("公共规则"), body, span)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FormatterTests(unittest.TestCase):
|
|
41
|
+
def test_readable_model_call_branch_and_constraint(self):
|
|
42
|
+
program = example_program()
|
|
43
|
+
self.assertEqual(format_ir(program), "\n".join((
|
|
44
|
+
"# AnnoScript IR v1 (display notation)",
|
|
45
|
+
"system_prompt '公共规则'",
|
|
46
|
+
"program 'demo':",
|
|
47
|
+
" text = anno.state['text']",
|
|
48
|
+
" decision = anno.choice('判断事件', {'yes': '事件', 'no': '非事件'})",
|
|
49
|
+
" if (decision.choice == 'yes'):",
|
|
50
|
+
" constrain((len(text) > 0), message='必须有正文')",
|
|
51
|
+
" return text",
|
|
52
|
+
" else:",
|
|
53
|
+
" return None",
|
|
54
|
+
"",
|
|
55
|
+
)))
|
|
56
|
+
self.assertEqual(format_ir(ir.Program.from_json(program.to_json())), format_ir(program))
|
|
57
|
+
|
|
58
|
+
def test_source_comments_are_optional(self):
|
|
59
|
+
program = example_program()
|
|
60
|
+
self.assertNotIn("missing.source.module", format_ir(program))
|
|
61
|
+
text = format_ir(program, show_source=True)
|
|
62
|
+
self.assertIn("# [s2] missing.source.module:1:1 (annotate)", text)
|
|
63
|
+
self.assertIn("inlined from caller:5:1 (entry)", text)
|
|
64
|
+
self.assertIn("decision = anno.choice", text)
|
|
65
|
+
|
|
66
|
+
def test_all_compiled_constructs_render_without_executing_models(self):
|
|
67
|
+
from annoscript import test_compiler as examples
|
|
68
|
+
for function in (
|
|
69
|
+
examples.loop_helpers, examples.expression_order, examples.short_circuit,
|
|
70
|
+
examples.constrained, examples.all_operations, examples.formatting,
|
|
71
|
+
):
|
|
72
|
+
with self.subTest(function=function.__name__):
|
|
73
|
+
program = compile_program(function)
|
|
74
|
+
before = program.to_dict()
|
|
75
|
+
text = format_ir(program)
|
|
76
|
+
self.assertTrue(text.endswith("\n"))
|
|
77
|
+
self.assertEqual(before, program.to_dict())
|
|
78
|
+
self.assertEqual(text, format_ir(ir.Program.from_json(program.to_json())))
|
|
79
|
+
loops = format_ir(compile_program(examples.loop_helpers))
|
|
80
|
+
self.assertIn("for ", loops)
|
|
81
|
+
formatting = format_ir(compile_program(examples.formatting))
|
|
82
|
+
self.assertIn("format_string(", formatting)
|
|
83
|
+
self.assertIn("format_value(", formatting)
|
|
84
|
+
self.assertIn("conversion='r'", formatting)
|
|
85
|
+
self.assertIn("json.dumps(", formatting)
|
|
86
|
+
calls = format_ir(compile_program(examples.all_operations))
|
|
87
|
+
for operation in ("noul", "choice", "score", "extract", "generate", "generate_list"):
|
|
88
|
+
self.assertIn(f"anno.{operation}(", calls)
|
|
89
|
+
|
|
90
|
+
def test_empty_blocks_literals_and_unusual_names(self):
|
|
91
|
+
program = example_program()
|
|
92
|
+
statement = ir.Return(ir.TupleExpr((ir.Literal("中文\n'{}\\"),)), id="s1", source=ir.Source(program.source))
|
|
93
|
+
program = replace(program, config=ir.ProgramConfig(), body=(statement,))
|
|
94
|
+
text = format_ir(program)
|
|
95
|
+
self.assertIn(repr("中文\n'{}\\"), text)
|
|
96
|
+
self.assertEqual(len(text.splitlines()), 3)
|
|
97
|
+
self.assertTrue(text.rstrip().endswith(",)"))
|
|
98
|
+
self.assertNotIn("system_prompt", text)
|
|
99
|
+
self.assertEqual(format_ir(replace(program, body=())).splitlines()[-1], " pass")
|
|
100
|
+
statement = replace(statement, value=ir.Var("has space"))
|
|
101
|
+
self.assertIn("var['has space']", format_ir(replace(program, body=(statement,))))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class InspectCommandTests(unittest.TestCase):
|
|
105
|
+
def run_command(self, *args, stdin=None):
|
|
106
|
+
return subprocess.run(
|
|
107
|
+
[sys.executable, "-m", "annoscript.inspect", *args], input=stdin,
|
|
108
|
+
text=True, encoding="utf-8", capture_output=True, cwd=_ROOT,
|
|
109
|
+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}, timeout=10,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def test_arbitrary_path_and_source_flag(self):
|
|
113
|
+
program = example_program()
|
|
114
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
115
|
+
path = Path(directory) / "任意 program.json"
|
|
116
|
+
path.write_text(program.to_json(), encoding="utf-8")
|
|
117
|
+
result = self.run_command(str(path), "--show-source")
|
|
118
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
119
|
+
self.assertEqual(result.stderr, "")
|
|
120
|
+
self.assertEqual(result.stdout, format_ir(program, show_source=True))
|
|
121
|
+
|
|
122
|
+
def test_stdin_and_help(self):
|
|
123
|
+
program = example_program()
|
|
124
|
+
result = self.run_command("-", stdin=program.to_json())
|
|
125
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
126
|
+
self.assertEqual(result.stdout, format_ir(program))
|
|
127
|
+
result = self.run_command("--help")
|
|
128
|
+
self.assertEqual(result.returncode, 0)
|
|
129
|
+
self.assertIn("--show-source", result.stdout)
|
|
130
|
+
|
|
131
|
+
def test_invalid_files_report_errors_without_tracebacks(self):
|
|
132
|
+
for text in ("not JSON", '{"format":"lev-ir","version":99}'):
|
|
133
|
+
with self.subTest(text=text):
|
|
134
|
+
result = self.run_command("-", stdin=text)
|
|
135
|
+
self.assertEqual(result.returncode, 2)
|
|
136
|
+
self.assertEqual(result.stdout, "")
|
|
137
|
+
self.assertIn("annoscript.inspect:", result.stderr)
|
|
138
|
+
self.assertNotIn("Traceback", result.stderr)
|
|
139
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
140
|
+
result = self.run_command(str(Path(directory) / "missing.json"))
|
|
141
|
+
self.assertEqual(result.returncode, 2)
|
|
142
|
+
self.assertIn("annoscript.inspect:", result.stderr)
|
|
143
|
+
|
|
144
|
+
def test_main_can_be_embedded_without_exiting_process(self):
|
|
145
|
+
output = io.StringIO()
|
|
146
|
+
with patch("sys.stdin", io.StringIO(example_program().to_json())), patch("sys.stdout", output):
|
|
147
|
+
status = main(["-"])
|
|
148
|
+
self.assertEqual(status, 0)
|
|
149
|
+
self.assertEqual(output.getvalue(), format_ir(example_program()))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
unittest.main()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared admission and nullable semantics; synthetic/local HTTP only."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from annoscript._dispatch_pool import RequestSlots
|
|
10
|
+
from annoscript.backends import BatchResult, InferenceOutput, DecisionAPIBackend, LLMAPIBackend
|
|
11
|
+
from annoscript.compiler import compile_program
|
|
12
|
+
from annoscript.anno import ChoiceResponse, GenerateResponse
|
|
13
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
14
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
15
|
+
from annoscript.server.worker import Worker
|
|
16
|
+
from annoscript.server.store import Store
|
|
17
|
+
from annoscript.server.releases import Releases
|
|
18
|
+
from annoscript.test_jobs_server import configurations
|
|
19
|
+
from annoscript.remote import ir_sha256
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def join_ready(anno):
|
|
23
|
+
first = anno.choice("ready immediately", {"yes": "yes", "no": "no"}).choice
|
|
24
|
+
text = anno.generate("synthetic", max_length=10).text
|
|
25
|
+
second = anno.choice(text, {"yes": "yes", "no": "no"}).choice
|
|
26
|
+
return [first, second]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SlotTests(unittest.IsolatedAsyncioTestCase):
|
|
30
|
+
async def test_fifo_cancel_duplicate_wait_and_idempotent_release(self):
|
|
31
|
+
p = RequestSlots(generation=1, decision=1)
|
|
32
|
+
woke = []
|
|
33
|
+
r = p.acquire("llm", "a", lambda: woke.append("a"))
|
|
34
|
+
self.assertIsNone(p.acquire("llm", "b", lambda: woke.append("b")))
|
|
35
|
+
self.assertIsNone(p.acquire("llm", "c", lambda: woke.append("c")))
|
|
36
|
+
self.assertIsNone(p.acquire("llm", "b", lambda: woke.append("duplicate")))
|
|
37
|
+
r(); r()
|
|
38
|
+
self.assertEqual(woke, ["b"])
|
|
39
|
+
self.assertIsNone(p.acquire("llm", "c", lambda: None))
|
|
40
|
+
p.cancel("b")
|
|
41
|
+
c = p.acquire("llm", "c", lambda: None); c()
|
|
42
|
+
self.assertEqual(p.active, {"llm": 0, "decision": 0})
|
|
43
|
+
self.assertFalse(p.waiting("b"))
|
|
44
|
+
|
|
45
|
+
async def test_waiting_batches_are_selected_at_admission(self):
|
|
46
|
+
pool = RequestSlots(generation=8, decision=1)
|
|
47
|
+
release = pool.acquire("decision", "external", lambda: None)
|
|
48
|
+
batches = []
|
|
49
|
+
generated = asyncio.Event()
|
|
50
|
+
class Backend:
|
|
51
|
+
async def infer(self, ctx, call):
|
|
52
|
+
generated.set()
|
|
53
|
+
return InferenceOutput(GenerateResponse("newly ready"))
|
|
54
|
+
async def infer_batch(self, ctx, calls):
|
|
55
|
+
batches.append([c.arguments["instructions"] for c in calls])
|
|
56
|
+
return BatchResult({c.id: ChoiceResponse("yes", 1., {"yes": 1., "no": 0.}) for c in calls})
|
|
57
|
+
async def aclose(self): pass
|
|
58
|
+
b = Backend()
|
|
59
|
+
async with AnnoRuntime(b, b, request_slots=pool, trace=True, max_decision_inflight=1) as runtime:
|
|
60
|
+
stream = await runtime.submit(lower_to_plan(compile_program(join_ready)), [RunInput(0, "fixture")])
|
|
61
|
+
await generated.wait()
|
|
62
|
+
for _ in range(8): await asyncio.sleep(0)
|
|
63
|
+
self.assertFalse(batches)
|
|
64
|
+
release()
|
|
65
|
+
result = [r async for r in stream]
|
|
66
|
+
self.assertEqual(result[0].value, ["yes", "yes"])
|
|
67
|
+
self.assertEqual(batches, [["ready immediately", "newly ready"]])
|
|
68
|
+
self.assertTrue(any(e['event'] == 'slot_wait' for e in runtime.trace))
|
|
69
|
+
self.assertEqual(sum(pool.active.values()), 0)
|
|
70
|
+
|
|
71
|
+
async def test_cancel_waiter_does_not_send_or_leak(self):
|
|
72
|
+
pool = RequestSlots(generation=1, decision=1)
|
|
73
|
+
a = pool.acquire('decision', 'held', lambda: None)
|
|
74
|
+
b = pool.acquire('llm', 'held', lambda: None)
|
|
75
|
+
class Never:
|
|
76
|
+
async def infer(self, *a, **k): raise AssertionError('sent')
|
|
77
|
+
async def infer_batch(self, *a, **k): raise AssertionError('sent')
|
|
78
|
+
async def aclose(self): pass
|
|
79
|
+
async with AnnoRuntime(Never(), Never(), request_slots=pool) as rt:
|
|
80
|
+
stream = await rt.submit(lower_to_plan(compile_program(join_ready)), [RunInput(0, 'fixture')])
|
|
81
|
+
for _ in range(8): await asyncio.sleep(0)
|
|
82
|
+
await stream.aclose()
|
|
83
|
+
a(); b()
|
|
84
|
+
self.assertFalse(any(pool.waiters.values()))
|
|
85
|
+
self.assertEqual(sum(pool.active.values()), 0)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SharedWorkerTests(unittest.IsolatedAsyncioTestCase):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
from annoscript.test_generation_policy import P0ProtocolWorkerTests, P0DiagnosticWorkerTests
|
|
93
|
+
|
|
94
|
+
class SharedProtocolTests(P0ProtocolWorkerTests):
|
|
95
|
+
def run_http(self, handler, *, observer=None):
|
|
96
|
+
w = super().run_http(handler, observer=observer)
|
|
97
|
+
w.concurrency = 8
|
|
98
|
+
w.request_slots = RequestSlots(generation=8, decision=4)
|
|
99
|
+
return w
|
|
100
|
+
|
|
101
|
+
class SharedDiagnosticTests(P0DiagnosticWorkerTests):
|
|
102
|
+
def worker(self, handler, **kw):
|
|
103
|
+
w = super().worker(handler, **kw)
|
|
104
|
+
w.concurrency = 8
|
|
105
|
+
w.request_slots = RequestSlots(generation=8, decision=4)
|
|
106
|
+
return w
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == '__main__': unittest.main()
|