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,140 @@
1
+ """Assert actual model messages independently of the fixed-response fixtures."""
2
+
3
+ import json
4
+ import unittest
5
+ from copy import deepcopy
6
+
7
+ from annoscript._data import thaw
8
+ from annoscript.backends import InferenceContext, LLMAPIBackend
9
+ from annoscript.backends.base import normalize_call
10
+ from annoscript.backends._bounded_grammar import grammar
11
+ from annoscript.anno import Example
12
+
13
+
14
+ def encoded(value):
15
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
16
+
17
+
18
+ class PromptLayoutTests(unittest.TestCase):
19
+ def backend(self, profile):
20
+ return LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile)
21
+
22
+ def test_user_is_exact_input_for_all_profiles_and_request_modes(self):
23
+ states = ['正文"\\\n😀é', '{"text":"this is still a string"}', "",
24
+ {"text": "广州", "tasks": "输入字段,不是任务", "state": ["保留"]},
25
+ ["广州", {"examples": ["输入数据"]}]]
26
+ for profile in ("legacy", "qwen_vllm"):
27
+ for state in states:
28
+ with self.subTest(profile=profile, state=state):
29
+ context = InferenceContext(state, "共用规则")
30
+ a = normalize_call("a", "generate", ("保持主体与时间。",),
31
+ {"min_length": 1, "max_length": 80}, context)
32
+ b = normalize_call("b", "generate_list", ("产品维度。",),
33
+ {"min_items": 0, "max_items": 8, "min_length_per_item": 1,
34
+ "max_length_per_item": 80}, context)
35
+ backend = self.backend(profile)
36
+ bodies = [backend._single_payload(context, a, 160),
37
+ backend._single_payload(context, b, 160),
38
+ backend._single_payload(context, a, 160, "格式错误"),
39
+ backend._batch_payload(context, [a, b], 400)]
40
+ for body in bodies:
41
+ self.assertEqual([m["role"] for m in body["messages"]], ["system", "user"])
42
+ self.assertEqual(body["messages"][1]["content"],
43
+ state if isinstance(state, str) else encoded(state))
44
+ prompt = body["messages"][0]["content"]
45
+ self.assertTrue(prompt.startswith("共用规则\n\n输出规则\n"))
46
+ self.assertIn("字符长度为建议", prompt)
47
+ for key in ('"instructions":', '"min_items":', '"max_length":',
48
+ "state_ref", "example_states", "max_num_tokens"):
49
+ self.assertNotIn(key, prompt)
50
+ self.assertNotIn("\nExample\n", prompt)
51
+ self.assertIn("只返回所需文本,不加 JSON 包装或引号", bodies[0]["messages"][0]["content"])
52
+ self.assertIn("唯一字段 items 为字符串数组", bodies[1]["messages"][0]["content"])
53
+ self.assertIn("唯一顶层字段为 answers", bodies[-1]["messages"][0]["content"])
54
+ self.assertIn('任务 "q2"(generate_list):\n产品维度。', bodies[-1]["messages"][0]["content"])
55
+
56
+ def test_examples_group_inputs_without_overwriting_answers_or_changing_calls(self):
57
+ first = {"text": "示例广州", "meta": ["甲"]}
58
+ reordered = {"meta": ["甲"], "text": "示例广州"}
59
+ second = {"text": "示例南京"}
60
+ context = InferenceContext({"text": "当前北京"}, "共享规则")
61
+ a = normalize_call("a", "generate", ("生成标题。",),
62
+ {"max_length": 80, "examples": [
63
+ Example(first, '标题"甲"\n😀'), Example(second, "标题乙"),
64
+ Example(reordered, "另一答案")]}, context)
65
+ b = normalize_call("b", "extract", ("地点。",),
66
+ {"source_field": "text", "max_items": 2, "max_length_per_item": 80,
67
+ "examples": [Example(reordered, ["广州"])]}, context)
68
+ c = normalize_call("c", "generate_list", ("维度。",),
69
+ {"max_items": 1, "max_length_per_item": 80}, context)
70
+ original = [deepcopy(thaw(call.arguments)) for call in (a, b, c)]
71
+ for profile in ("legacy", "qwen_vllm"):
72
+ backend = self.backend(profile)
73
+ body = backend._batch_payload(context, [a, b, c], 400)
74
+ prompt = body["messages"][0]["content"]
75
+ rules, examples = prompt.split("\n\nExample\n")
76
+ self.assertNotIn("当前北京", prompt)
77
+ self.assertNotIn("示例广州", rules)
78
+ self.assertEqual(examples.count("输入:" + encoded(first)), 1)
79
+ self.assertNotIn("输入:" + encoded(reordered), examples)
80
+ self.assertLess(examples.index("示例广州"), examples.index("示例南京"))
81
+ outputs = [json.loads(line.removeprefix("示例输出:")) for line in examples.splitlines()
82
+ if line.startswith("示例输出:")]
83
+ self.assertEqual(outputs, [{"answers": {"q1": '标题"甲"\n😀'}},
84
+ {"answers": {"q1": "另一答案", "q2": ["广州"]}},
85
+ {"answers": {"q1": "标题乙"}}])
86
+ self.assertNotIn('"q3"', examples)
87
+ self.assertIn("当前正式回答必须包含全部任务", examples)
88
+ self.assertNotIn("state_ref", prompt)
89
+ repair = backend._single_payload(context, b, 160, "格式错误")
90
+ repaired_prompt = repair["messages"][0]["content"]
91
+ self.assertLess(repaired_prompt.index("格式错误"), repaired_prompt.index("\n\nExample\n"))
92
+ self.assertNotIn('任务 "a"', repaired_prompt)
93
+ self.assertNotIn("示例南京", repaired_prompt)
94
+ self.assertEqual(repair["messages"][1], body["messages"][1])
95
+ if profile == "qwen_vllm":
96
+ self.assertEqual(body["structured_outputs"]["grammar"], grammar([a, b, c], batch=True))
97
+ self.assertEqual(repair["structured_outputs"]["grammar"], grammar([b]))
98
+ self.assertEqual([thaw(call.arguments) for call in (a, b, c)], original)
99
+
100
+ def test_natural_limits_preserve_zero_nonempty_and_source_semantics(self):
101
+ for profile in ("legacy", "qwen_vllm"):
102
+ backend = self.backend(profile)
103
+ for source in (None, '正文"\\\n😀'):
104
+ context = InferenceContext("北京" if source is None else {source: "北京"})
105
+ call = normalize_call("e", "extract", ("逐字抽取。",),
106
+ {"source_field": source, "min_items": 1, "max_items": 8,
107
+ "max_length_per_item": 80}, context)
108
+ prompt = backend._single_payload(context, call, 160)["messages"][0]["content"]
109
+ self.assertIn("返回 1 至 8 项", prompt)
110
+ self.assertIn("非空连续原文,不得改写或拼接", prompt)
111
+ self.assertIn("每项建议不超过 80 字符", prompt)
112
+ self.assertIn("输入字符串" if source is None else "输入的顶层字段 " + encoded(source), prompt)
113
+ self.assertNotIn("北京", prompt)
114
+ context = InferenceContext([])
115
+ cases = [
116
+ ("generate", {"min_length": 0, "max_length": 0}, "允许空文本。建议长度为 0 至 0 字符"),
117
+ ("generate", {"min_length": 3, "max_length": 80}, "必须非空。建议长度为 3 至 80 字符"),
118
+ ("generate_list", {"max_items": 0, "max_length_per_item": 0}, "返回 0 至 0 项"),
119
+ ("generate_list", {"max_items": 8, "min_length_per_item": 3, "max_length_per_item": 80},
120
+ "每项为非空字符串。每项建议长度为 3 至 80 字符"),
121
+ ]
122
+ for operation, options, expected in cases:
123
+ call = normalize_call("q", operation, ("说明",), options, context)
124
+ self.assertIn(expected, backend._single_payload(context, call, 160)["messages"][0]["content"])
125
+
126
+ def test_profiles_share_messages_and_no_fixture_data_is_sent(self):
127
+ context = InferenceContext({"text": "当前输入"})
128
+ calls = [normalize_call("a", "generate", ("标题",),
129
+ {"max_length": 80, "examples": [Example(["示例输入"], "示例答案")]}, context),
130
+ normalize_call("b", "generate_list", ("维度",),
131
+ {"max_items": 8, "max_length_per_item": 80}, context)]
132
+ a, b = self.backend("legacy"), self.backend("qwen_vllm")
133
+ for feedback in (None, "错误"):
134
+ left = a._single_payload(context, calls[0], 160, feedback)
135
+ right = b._single_payload(context, calls[0], 160, feedback)
136
+ self.assertEqual(left["messages"], right["messages"])
137
+ self.assertEqual(set(left), {"model", "messages", "thinking", "max_tokens"})
138
+ self.assertEqual(set(right), {"model", "messages", "chat_template_kwargs", "max_tokens", "structured_outputs"})
139
+ self.assertEqual(a._batch_payload(context, calls, 400)["messages"],
140
+ b._batch_payload(context, calls, 400)["messages"])
@@ -0,0 +1,181 @@
1
+ """Qwen wire/configuration contracts and real Runtime with offline HTTP responses."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import httpx
12
+
13
+ from annoscript.backends import InferenceContext, InferenceError
14
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
15
+ from annoscript.backends._grammar import ITEMS_GRAMMAR, batch_grammar
16
+ from annoscript.backends._bounded_grammar import grammar
17
+ from annoscript._test_evaluation_http import llm_content, llm_calls
18
+ from annoscript.backends.base import normalize_call
19
+ from annoscript.backends.config import backends_from_env
20
+ from annoscript.client import AnnoClient
21
+
22
+
23
+ def completion(content, finish="stop", **message):
24
+ return httpx.Response(200, json={"choices": [{"finish_reason": finish,
25
+ "message": {"content": content, **message}}]})
26
+
27
+
28
+ class QwenBackendTests(unittest.IsolatedAsyncioTestCase):
29
+ async def test_profiles_thinking_operations_and_token_budgets(self):
30
+ context = InferenceContext({"text": "广州"}, "共同规则")
31
+ for profile in ("legacy", "qwen_vllm"):
32
+ for disabled in (True, False):
33
+ for operation in ("generate", "generate_list", "extract"):
34
+ for budget in ((None, 7) if operation != "extract" else (None,)):
35
+ with self.subTest(profile=profile, disabled=disabled, operation=operation, budget=budget):
36
+ requests = []
37
+
38
+ def handle(request):
39
+ body = json.loads(request.content)
40
+ requests.append(body)
41
+ self.assertEqual(str(request.url), "https://llm.invalid/v1/chat/completions")
42
+ self.assertEqual(body["model"], "qwen-model-name-is-not-detection")
43
+ if disabled and profile == "legacy":
44
+ self.assertEqual(body["thinking"], {"type": "disabled"})
45
+ else:
46
+ self.assertNotIn("thinking", body)
47
+ if disabled and profile == "qwen_vllm":
48
+ self.assertEqual(body["chat_template_kwargs"], {"enable_thinking": False})
49
+ else:
50
+ self.assertNotIn("chat_template_kwargs", body)
51
+ if operation == "generate":
52
+ self.assertNotIn("response_format", body)
53
+ self.assertEqual("structured_outputs" in body, profile=="qwen_vllm")
54
+ elif profile == "qwen_vllm":
55
+ self.assertEqual(body["structured_outputs"], {"grammar": grammar([normalize_call("q", operation, ("正文",), options, context)])})
56
+ self.assertNotIn("response_format", body)
57
+ else:
58
+ self.assertEqual(body["response_format"], {"type": "json_object"})
59
+ self.assertNotIn("structured_outputs", body)
60
+ if budget is None:
61
+ self.assertEqual(body["max_tokens"], 1024)
62
+ else:
63
+ self.assertEqual(body["max_tokens"], budget)
64
+ return completion("广州" if operation == "generate" else '{"items":["广州"]}')
65
+
66
+ options = {"source_field": "text"} if operation == "extract" else {"max_num_tokens": budget}
67
+ options.update({"max_length":512} if operation=="generate" else {"max_items":8,"max_length_per_item":58})
68
+ backend = LLMAPIBackend("https://llm.invalid/v1", "fixture", "qwen-model-name-is-not-detection",
69
+ profile=profile, disable_thinking=disabled, transport=httpx.MockTransport(handle))
70
+ try:
71
+ await backend.infer(context, normalize_call("q", operation, ("正文",), options, context))
72
+ self.assertEqual(len(requests), 1)
73
+ finally:
74
+ await backend.aclose()
75
+
76
+ async def test_http_rejection_is_not_downgraded_or_retried(self):
77
+ context = InferenceContext("正文")
78
+ call = normalize_call("q", "generate_list", ("列举",), {'max_items': 8, 'max_length_per_item': 58}, context)
79
+ for status in (400, 401, 403, 408, 422, 429, 500, 503):
80
+ with self.subTest(status=status):
81
+ requests = []
82
+
83
+ def handle(request):
84
+ requests.append(json.loads(request.content))
85
+ return httpx.Response(status, text="private model response", headers={"Retry-After": "9"})
86
+
87
+ backend = LLMAPIBackend("https://llm.invalid", "fixture", "model", profile="qwen_vllm",
88
+ transport=httpx.MockTransport(handle))
89
+ try:
90
+ with self.assertRaises(InferenceError) as caught:
91
+ await backend.infer(context, call)
92
+ self.assertEqual(caught.exception.metadata, {"http_status": status, "retry_after": "9"})
93
+ self.assertNotIn("private", str(caught.exception))
94
+ self.assertEqual(len(requests), 1)
95
+ self.assertIn("grammar", requests[0]["structured_outputs"])
96
+ self.assertNotIn("response_format", requests[0])
97
+ finally:
98
+ await backend.aclose()
99
+
100
+ async def test_invalid_output_and_truncation_do_not_trigger_repair(self):
101
+ context = InferenceContext("广州")
102
+ call = normalize_call("q", "extract", ("地点",), {"min_items": 1, 'max_items': 8, 'max_length_per_item': 58}, context)
103
+ cases = [
104
+ ("{}", "stop", {}), ('{"items":["北京"]}', "stop", {}),
105
+ ('{"items":[]}', "stop", {}), ('{"items":[null]}', "stop", {}),
106
+ ('{"items":[""]}', "stop", {}), ('{"items":["广州"],"extra":0}', "stop", {}),
107
+ ('{"items":[', "stop", {}), ('{"items":["广州"]}', "length", {}),
108
+ (None, "stop", {}), ('{"items":["广州"]}', "stop", {"refusal": "refused"}),
109
+ ]
110
+ for content, finish, message in cases:
111
+ requests = []
112
+
113
+ def handle(request):
114
+ requests.append(request)
115
+ return completion(content, finish, **message)
116
+
117
+ backend = LLMAPIBackend("https://llm.invalid", "fixture", "model", profile="qwen_vllm",
118
+ transport=httpx.MockTransport(handle))
119
+ try:
120
+ with self.subTest(content=content, finish=finish), self.assertRaises(InferenceError) as caught:
121
+ await backend.infer(context, call)
122
+ self.assertEqual(caught.exception.code, "incomplete_generation" if finish == "length" else "invalid_response")
123
+ self.assertEqual(len(requests), 1)
124
+ finally:
125
+ await backend.aclose()
126
+
127
+ async def test_decoded_strings_are_preserved_and_count_unicode_characters(self):
128
+ source = ' 中文😀 "引号" \\路径\n换行\t '
129
+ context = InferenceContext(source)
130
+ call = normalize_call("q", "extract", ("原文",), {"max_length_per_item": len(source), 'max_items': 8}, context)
131
+ for ascii_only in (True, False):
132
+ backend = LLMAPIBackend("https://llm.invalid", "fixture", "model", profile="qwen_vllm",
133
+ transport=httpx.MockTransport(lambda _: completion(json.dumps({"items": [source]}, ensure_ascii=ascii_only))))
134
+ try:
135
+ response = await backend.infer(context, call)
136
+ self.assertEqual(response.response.items, [source])
137
+ finally:
138
+ await backend.aclose()
139
+
140
+
141
+ class ConfigurationTests(unittest.TestCase):
142
+ def test_invalid_configuration_fails_before_any_http(self):
143
+ with patch("httpx.AsyncClient", side_effect=AssertionError("HTTP must not be constructed")):
144
+ for profile in (None, "", "Qwen", 1, True, [], {}):
145
+ with self.subTest(profile=profile), self.assertRaises(ValueError):
146
+ LLMAPIBackend("https://llm.invalid", "fixture", "model", profile=profile)
147
+ for disabled in (None, "false", 0, 1):
148
+ with self.subTest(disabled=disabled), self.assertRaises(ValueError):
149
+ LLMAPIBackend("https://llm.invalid", "fixture", "model", disable_thinking=disabled)
150
+
151
+ def test_environment_precedence_and_public_client_forwarding(self):
152
+ credentials = {f"ANNO_{prefix}_{suffix}": value for prefix in ("DECISION", "GENERATE")
153
+ for suffix, value in (("BASE_URL", "https://offline.invalid"),
154
+ ("AUTH_TOKEN", "fixture"), ("MODEL", "model"))}
155
+ with tempfile.TemporaryDirectory() as directory:
156
+ path = Path(directory, "backend.env")
157
+ path.write_text("ANNO_GENERATE_PROFILE=qwen_vllm\n")
158
+ with patch.dict(os.environ, credentials, clear=True):
159
+ self.assertEqual(backends_from_env()[1].profile, "legacy")
160
+ self.assertEqual(backends_from_env(env_file=path)[1].profile, "qwen_vllm")
161
+ with AnnoClient.from_env(env_file=path, generation_profile="legacy", disable_thinking=False) as client:
162
+ self.assertEqual(client.runtime.llm_backend.profile, "legacy")
163
+ self.assertFalse(client.runtime.llm_backend.disable_thinking)
164
+ with patch.dict(os.environ, {"ANNO_GENERATE_PROFILE": "legacy"}):
165
+ self.assertEqual(backends_from_env(env_file=path)[1].profile, "legacy")
166
+ self.assertEqual(backends_from_env(env_file=path, generation_profile="qwen_vllm")[1].profile, "qwen_vllm")
167
+ with patch.dict(os.environ, {"ANNO_GENERATE_PROFILE": "invalid"}):
168
+ with self.assertRaises(ValueError):
169
+ backends_from_env()
170
+ self.assertEqual(backends_from_env(generation_profile="legacy")[1].profile, "legacy")
171
+ for profile in ("", 0, False):
172
+ with self.assertRaises(ValueError):
173
+ backends_from_env(generation_profile=profile)
174
+
175
+
176
+ class CarRuntimeTests(unittest.TestCase):
177
+ pass
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()
@@ -0,0 +1,180 @@
1
+ """Offline regressions for Qwen layout, paused dispatch and uncertain outcomes."""
2
+ import asyncio
3
+ import json
4
+ import threading
5
+ import unittest
6
+ from unittest.mock import patch
7
+ import httpx
8
+ from annoscript.backends import InferenceContext, DecisionAPIBackend, BackendFatalError, InferenceError
9
+ from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
10
+ from annoscript.backends.base import normalize_call
11
+ from annoscript.server.worker import Worker, classification
12
+ from annoscript.server.metrics import RequestMeter
13
+ from annoscript.server.releases import Releases
14
+ from annoscript.test_jobs_server import StoreTests, configurations, payload
15
+
16
+
17
+ def steps(anno):
18
+ first = anno.generate('first', max_length=512).text
19
+ return anno.generate(first, max_length=512).text
20
+
21
+
22
+ def completion(text='next'):
23
+ return httpx.Response(200, json={'choices':[{'finish_reason':'stop','message':{'content':text}}],
24
+ 'usage':{'prompt_tokens':7,'completion_tokens':2}})
25
+
26
+
27
+ class LayoutTests(unittest.IsolatedAsyncioTestCase):
28
+ async def test_single_batch_repair_and_empty_policy_keep_contents_and_budgets(self):
29
+ for policy in (None, '', '共同规则'):
30
+ context = InferenceContext({'text':'广州'}, policy)
31
+ calls = [normalize_call('a','extract',('地点',),{'source_field':'text', 'max_items': 8, 'max_length_per_item': 58},context),
32
+ normalize_call('b','generate_list',('维度',),{'max_num_tokens':32, 'max_items': 8, 'max_length_per_item': 58},context)]
33
+ for mode in ('single','repair','batch'):
34
+ bodies = {}
35
+ for profile in ('legacy','qwen_vllm'):
36
+ def handle(req):
37
+ body=json.loads(req.content);bodies[profile]=body
38
+ return completion(json.dumps({'answers':{'q1':['广州'],'q2':['空间']}} if mode=='batch' else {'items':['广州']}))
39
+ backend=LLMAPIBackend('https://offline.invalid/v1','fixture','model',profile=profile,
40
+ transport=httpx.MockTransport(handle))
41
+ try:
42
+ if mode=='batch':await backend.infer_batch(context,calls,max_tokens=2176)
43
+ else:await backend.infer(context,calls[0],**({'repair_feedback':'bad answer','max_tokens_override':128} if mode=='repair' else {}))
44
+ finally:await backend.aclose()
45
+ legacy,qwen=bodies['legacy'],bodies['qwen_vllm']
46
+ self.assertEqual([m['role'] for m in qwen['messages']],['system','user'])
47
+ self.assertEqual(legacy['messages'],qwen['messages'])
48
+ self.assertIn('输入的顶层字段 "text"',qwen['messages'][0]['content'])
49
+ if policy:
50
+ self.assertTrue(qwen['messages'][0]['content'].startswith(policy+'\n'))
51
+ from annoscript._test_evaluation_http import llm_calls
52
+ a,b=llm_calls(legacy),llm_calls(qwen)
53
+ self.assertEqual(a[0],b[0])
54
+ self.assertEqual([(c.state,x.operation,x.arguments) for c,x in a[1]],
55
+ [(c.state,x.operation,x.arguments) for c,x in b[1]])
56
+ self.assertEqual(qwen.get('max_tokens'),legacy.get('max_tokens'))
57
+ self.assertEqual(len(legacy['messages']),2)
58
+
59
+
60
+ class ProtocolTests(StoreTests):
61
+ # Inherited store tests remain a separate baseline; pytest collection here
62
+ # also exercises them against the new policy defaults.
63
+ def config(self, **policy):
64
+ self.releases=Releases(configurations(**policy),'test')
65
+
66
+ def run_http(self, handler, *, observer=None):
67
+ def factory(_):
68
+ return (DecisionAPIBackend('https://offline.invalid','fixture','model',transport=httpx.MockTransport(handler)),
69
+ LLMAPIBackend('https://offline.invalid/v1','fixture','model',profile='qwen_vllm',transport=httpx.MockTransport(handler)))
70
+ return Worker(self.store,self.releases,backend_factory=factory,concurrency=4,request_observer=observer)
71
+
72
+ def test_protocol_auth_context_and_retry_classification(self):
73
+ for code,metadata,expected in [
74
+ ('backend_protocol_error',{'http_status':400},'pause'),
75
+ ('authentication_failed',{'http_status':401},'pause'),
76
+ ('model_http_error',{'http_status':400},'result'),
77
+ ('model_timeout',{},'pause'),('transport_error',{},'pause'),
78
+ ('backend_unavailable',{},'retry'),
79
+ ('model_http_error',{'http_status':429},'retry'),
80
+ ('model_http_error',{'http_status':503},'retry')]:
81
+ self.assertEqual(classification(code,metadata,retry_unknown_outcomes=False),expected)
82
+ self.assertEqual(classification('model_timeout',{}),'retry')
83
+
84
+ def test_four_inflight_workers_drain_after_protocol_pause_without_new_dispatch(self):
85
+ self.config(retry_unknown_outcomes=False)
86
+ job=self.store.submit('alice','protocol',payload(tuple(str(i) for i in range(8)),program=steps),self.releases)
87
+ claims=[self.claim() for _ in range(4)];audit=[];calls=[]
88
+ async def run():
89
+ started=asyncio.Event();release=asyncio.Event()
90
+ async def handler(req):
91
+ body=json.loads(req.content);state=body['messages'][-1]['content'];calls.append(state)
92
+ if len(calls)==4:started.set()
93
+ await started.wait()
94
+ if state=='0':return httpx.Response(400,json={'error':{'message':'System message must be at the beginning.'}})
95
+ await release.wait()
96
+ return completion()
97
+ worker=self.run_http(handler,observer=audit.append)
98
+ tasks=[asyncio.create_task(worker.execute(c)) for c in claims]
99
+ async with asyncio.timeout(10):
100
+ while self.store.status('alice',job['job_id'])['state']!='paused':await asyncio.sleep(.005)
101
+ self.assertIsNone(self.claim())
102
+ self.assertTrue(all(self.store.renew(c) for c in claims[1:]))
103
+ self.assertTrue(all(not self.store.can_dispatch(c) for c in claims[1:]))
104
+ release.set();await asyncio.gather(*tasks)
105
+ asyncio.run(run())
106
+ self.assertEqual(len(calls),4)
107
+ status=self.store.status('alice',job['job_id'])
108
+ self.assertEqual(status['pause_reason']['code'],'backend_protocol_error')
109
+ self.assertEqual(status['counts']['completed'],0)
110
+ with self.store._tx() as db:
111
+ rows=db.execute('SELECT http_status,seconds,usage FROM http_requests').fetchall()
112
+ self.assertEqual(sorted(r['http_status'] for r in rows),[200,200,200,400])
113
+ self.assertTrue(all(r['seconds'] is not None for r in rows))
114
+ self.assertEqual(len([x for x in audit if x['event']=='outcome']),4)
115
+ self.assertTrue(all(x['storage_committed'] for x in audit if x['event']=='outcome'))
116
+
117
+ def test_context_error_is_local_and_unknown_timeout_pauses_once(self):
118
+ for name,unknown in [('context',False),('timeout',True)]:
119
+ self.config(retry_unknown_outcomes=False)
120
+ job=self.submit((name,),key=name);calls=[]
121
+ async def handler(req):
122
+ calls.append(req)
123
+ if unknown:raise httpx.ReadTimeout('fixture')
124
+ return httpx.Response(400,json={'error':{'message':'maximum context length is 8192 tokens'}})
125
+ asyncio.run(self.run_http(handler).execute(self.claim()))
126
+ self.assertEqual(len(calls),1)
127
+ status=self.store.status('alice',job['job_id'])
128
+ self.assertEqual(status['state'],'paused' if unknown else 'completed')
129
+ if unknown:self.assertIsNone(self.claim())
130
+ else:self.assertEqual(self.store.results('alice',job['job_id'])['results'][0]['error']['code'],'model_http_error')
131
+
132
+ def test_pause_gate_at_meter_boundary_preserves_existing_response(self):
133
+ job=self.submit(('one','two'));a,b=self.claim(),self.claim()
134
+ self.store.request_started(b,'inflight','generation',1)
135
+ self.store.attempt_failed(a,{'code':'backend_protocol_error','message':'fixture'},pause=True)
136
+ self.assertFalse(self.store.request_started(b,'blocked','generation',1))
137
+ self.assertTrue(self.store.request_finished(b,'inflight',seconds=1,http_status=200,usage={'prompt_tokens':7}))
138
+ async def run():
139
+ with self.assertRaises(BackendFatalError) as e:await RequestMeter(self.store,b,'generation').start(1)
140
+ self.assertEqual(e.exception.code,'job_paused')
141
+ asyncio.run(run())
142
+
143
+ def test_observer_failure_never_retries_model(self):
144
+ for phase,expected in [('intent',0),('outcome',1)]:
145
+ self.config(retry_unknown_outcomes=False)
146
+ job=self.submit((phase,),key=phase);calls=[]
147
+ def observe(event):
148
+ if event['event']==phase:raise OSError('fixture storage unavailable')
149
+ def handler(req):calls.append(req);return completion()
150
+ asyncio.run(self.run_http(handler,observer=observe).execute(self.claim()))
151
+ self.assertEqual(len(calls),expected)
152
+ self.assertEqual(self.store.status('alice',job['job_id'])['pause_reason']['code'],'metrics_failed')
153
+ self.assertIsNone(self.claim())
154
+
155
+ def test_cancel_during_commit_still_audits_observed_outcome_once(self):
156
+ self.submit(('a',));claim=self.claim();audit=[];meter=RequestMeter(self.store,claim,'generation',observer=audit.append)
157
+ entered=threading.Event();release=threading.Event();original=self.store.request_finished
158
+ def finish(*args,**kwargs):entered.set();release.wait(5);return original(*args,**kwargs)
159
+ async def run():
160
+ identity=await meter.start(1)
161
+ with patch.object(self.store,'request_finished',side_effect=finish):
162
+ task=asyncio.create_task(meter.finish(identity,.1,200,{'prompt_tokens':5,'completion_tokens':1}))
163
+ async with asyncio.timeout(5):
164
+ while not entered.is_set():await asyncio.sleep(.005)
165
+ task.cancel();release.set()
166
+ with self.assertRaises(asyncio.CancelledError):await task
167
+ asyncio.run(run())
168
+ outcomes=[e for e in audit if e['event']=='outcome']
169
+ self.assertEqual(len(outcomes),1);self.assertTrue(outcomes[0]['storage_committed'])
170
+ self.assertEqual(outcomes[0]['usage']['prompt_tokens'],5)
171
+ self.assertIsNotNone(outcomes[0]['finished_unix'])
172
+
173
+ def test_policy_boolean_digest_default_and_abandoned_attempt(self):
174
+ default=self.releases.default
175
+ self.config(retry_unknown_outcomes=True);self.assertEqual(default,self.releases.default)
176
+ self.config(retry_unknown_outcomes=False);self.assertNotEqual(default,self.releases.default)
177
+ for invalid in (0,1,'false',None):
178
+ with self.assertRaises(ValueError):Releases(configurations(retry_unknown_outcomes=invalid),'test')
179
+ job=self.submit(('a',));self.claim();self.clock.now+=61
180
+ self.assertIsNone(self.claim());self.assertEqual(self.store.status('alice',job['job_id'])['state'],'paused')
@@ -0,0 +1,20 @@
1
+ import hashlib
2
+ import unittest
3
+
4
+ import httpx
5
+
6
+ from annoscript.job_protocol import encode
7
+ from annoscript.jobs import AnnoJobClient, RemoteProtocolError
8
+
9
+
10
+ class ReleaseTests(unittest.TestCase):
11
+ def test_digest_available_and_unavailable(self):
12
+ manifest = {"format": "lev-release", "version": 1, "policy": {"max_inputs": 500}}
13
+ rid = hashlib.sha256(encode(manifest, canonical=True)).hexdigest()
14
+ for available in (False, True):
15
+ payload = {"protocol_version": 1, "release_id": rid, "manifest": manifest, "available": available}
16
+ with AnnoJobClient("http://offline", transport=httpx.MockTransport(lambda r: httpx.Response(200, json=payload))) as client:
17
+ self.assertEqual(client.release(rid), payload)
18
+ payload["manifest"] = {"changed": True}
19
+ with self.assertRaises(RemoteProtocolError):
20
+ client.release(rid)