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,181 @@
1
+ """Protocol and contract checks use HTTPX transports, never real credentials."""
2
+
3
+ import asyncio
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 DecisionAPIBackend, LLMAPIBackend, InferenceContext, InferenceError, BackendFatalError
14
+ from annoscript.backends.base import normalize_call, validate_response
15
+ from annoscript.backends.config import backends_from_env
16
+ from annoscript.anno import ChoiceResponse, GenerateResponse, ExtractResponse, GenerateListResponse
17
+
18
+
19
+ class BackendTests(unittest.IsolatedAsyncioTestCase):
20
+ async def test_decision_mixed_payload_and_id_mapping(self):
21
+ context = InferenceContext({"text": "中文"}, "共同规则")
22
+ calls = [normalize_call("q1", "noul", ("是否",), {}, context),
23
+ normalize_call("q2", "choice", ("选项", {"a": "甲", "b": "乙"}), {}, context),
24
+ normalize_call("q3", "score", ("程度", ["低", "高"]), {}, context)]
25
+ async def handle(request):
26
+ self.assertEqual(str(request.url), "https://decision.invalid/full/path")
27
+ self.assertEqual(request.headers["Authorization"], "Bearer secret")
28
+ body = json.loads(request.content)
29
+ self.assertEqual(body["state"], {"text": "中文"})
30
+ self.assertEqual(list(body["questions"]), ["q1", "q2", "q3"])
31
+ self.assertNotIn("criteria", body["questions"]["q1"])
32
+ self.assertEqual([q["instructions"] for q in body["questions"].values()], ["是否", "选项", "程度"])
33
+ return httpx.Response(200, json={"answers": {
34
+ "q3": {"type": "score", "score": 0.7, "confidence": 0.8, "probabilities": {"0": 0.3, "1": 0.7}, "legend": {"0": "低", "1": "高"}},
35
+ "q1": {"type": "noul", "noul": 0.8},
36
+ "q2": {"type": "choice", "choice": "b", "confidence": 0.9, "probabilities": {"a": 0.1, "b": 0.9}},
37
+ }, "usage": {"input_tokens": 20, "output_tokens": 4}})
38
+ backend = DecisionAPIBackend("https://decision.invalid/full/path", "secret", "model", transport=httpx.MockTransport(handle))
39
+ try:
40
+ result = await backend.infer_batch(context, calls)
41
+ self.assertEqual(result.responses["q2"].choice, "b")
42
+ self.assertEqual(result.responses["q3"].legend, {0: "低", 1: "高"})
43
+ self.assertEqual(result.metadata["usage"], {"input_tokens": 20, "output_tokens": 4})
44
+ client = backend._client
45
+ await backend.infer_batch(context, calls)
46
+ self.assertIs(backend._client, client)
47
+ finally:
48
+ await backend.aclose()
49
+
50
+ async def test_llm_mapping_limits_extraction_source_and_thinking(self):
51
+ context = InferenceContext({"text": "原文甲", "other": "独立来源广州"}, "共享政策")
52
+ for operation, args, options, answer in (
53
+ ("generate", ("简述",), {"min_length": 1, "max_length": 2, "max_num_tokens": 16}, "中文"),
54
+ ("generate_list", ("列出",), {"min_items": 1, "max_items": 8, "max_length_per_item": 2}, '{"items":["中文"]}'),
55
+ ("extract", ("抽取",), {"source_field": "other", "min_items": 1, "max_items": 8, "max_length_per_item": 58}, '{"items":["广州"]}'),
56
+ ):
57
+ with self.subTest(operation=operation):
58
+ async def handle(request):
59
+ body = json.loads(request.content)
60
+ self.assertEqual(str(request.url), "https://llm.invalid/v1/chat/completions")
61
+ self.assertEqual(body["thinking"], {"type": "disabled"})
62
+ self.assertTrue(body["messages"][0]["content"].startswith("共享政策\n"))
63
+ payload = json.loads(body["messages"][-1]["content"])
64
+ self.assertEqual(payload, {"text": "原文甲", "other": "独立来源广州"})
65
+ if operation == "extract":
66
+ self.assertIn('输入的顶层字段 "other"', body["messages"][0]["content"])
67
+ if operation == "generate":
68
+ self.assertNotIn("response_format", body)
69
+ self.assertEqual(body["max_tokens"], 16)
70
+ else:
71
+ self.assertEqual(body["response_format"], {"type": "json_object"})
72
+ self.assertGreater(body["max_tokens"], 0)
73
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": answer, "reasoning_content": "never record this"}}], "usage": {"completion_tokens": 4}})
74
+ backend = LLMAPIBackend("https://llm.invalid/v1", "secret", "model", transport=httpx.MockTransport(handle))
75
+ try:
76
+ result = await backend.infer(context, normalize_call("q", operation, args, options, context))
77
+ self.assertNotIn("reasoning", json.dumps(result.metadata))
78
+ finally:
79
+ await backend.aclose()
80
+
81
+ async def test_http_failures_are_sanitized_and_not_retried(self):
82
+ context = InferenceContext("x")
83
+ call = normalize_call("q", "generate", ("task",), {'max_length': 512}, context)
84
+ for status, error in ((401, BackendFatalError), (403, BackendFatalError), (429, InferenceError), (500, InferenceError)):
85
+ count = 0
86
+ async def handle(request):
87
+ nonlocal count
88
+ count += 1
89
+ return httpx.Response(status, text="credential secret, body must not escape")
90
+ backend = LLMAPIBackend("https://llm.invalid", "secret", "model", transport=httpx.MockTransport(handle))
91
+ try:
92
+ with self.assertRaises(error) as caught:
93
+ await backend.infer(context, call)
94
+ self.assertNotIn("secret", str(caught.exception))
95
+ self.assertEqual(count, 1)
96
+ finally:
97
+ await backend.aclose()
98
+
99
+ async def test_bad_llm_results_never_become_success(self):
100
+ context = InferenceContext("广州")
101
+ call = normalize_call("q", "extract", ("地点",), {'max_items': 8, 'max_length_per_item': 58}, context)
102
+ for message, finish in (("{}", "stop"), ('{"items":["北京"]}', "stop"), ('{"items":[""]}', "stop"), ('{"items":[null]}', "stop"), ('{"items":[]}', "length"), ("```json\n{}\n```", "stop"), (None, "stop")):
103
+ backend = LLMAPIBackend("https://llm.invalid", "secret", "model", transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"choices": [{"finish_reason": finish, "message": {"content": message}}]})))
104
+ try:
105
+ with self.assertRaises(InferenceError):
106
+ await backend.infer(context, call)
107
+ finally:
108
+ await backend.aclose()
109
+
110
+ async def test_timeout_cancellation_and_model_default_thinking(self):
111
+ context = InferenceContext("x")
112
+ call = normalize_call("q", "generate", ("x",), {'max_length': 512}, context)
113
+ cancelled = asyncio.Event()
114
+ async def hang(request):
115
+ self.assertNotIn("thinking", json.loads(request.content))
116
+ try:
117
+ await asyncio.Event().wait()
118
+ finally:
119
+ cancelled.set()
120
+ backend = LLMAPIBackend("https://llm.invalid", "secret", "model", disable_thinking=False, timeout=0.01, transport=httpx.MockTransport(hang))
121
+ try:
122
+ with self.assertRaises(InferenceError) as caught:
123
+ await backend.infer(context, call)
124
+ self.assertEqual(caught.exception.code, "model_timeout")
125
+ self.assertTrue(cancelled.is_set())
126
+ finally:
127
+ await backend.aclose()
128
+
129
+ async def test_missing_decision_answers_and_wrong_types(self):
130
+ context = InferenceContext("x")
131
+ call = normalize_call("q", "choice", ("x", {"a": "A"}), {}, context)
132
+ for answers in ({}, {"q": {"type": "choice", "choice": "b", "confidence": 0.8, "probabilities": {"a": 1}}}, {"q": {"type": "noul", "noul": 0.5}}):
133
+ backend = DecisionAPIBackend("https://decision.invalid", "secret", "model", transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"answers": answers})))
134
+ try:
135
+ with self.assertRaises(InferenceError):
136
+ await backend.infer_batch(context, [call])
137
+ finally:
138
+ await backend.aclose()
139
+
140
+
141
+ class ContractTests(unittest.TestCase):
142
+ def test_input_validation_and_payload_immutability(self):
143
+ context = InferenceContext({"a": [1]})
144
+ with self.assertRaises(TypeError):
145
+ context.state["b"] = 2
146
+ for operation, args, options in (
147
+ ("generate", (1,), {}), ("generate", ("x",), {"max_num_tokens": 0}),
148
+ ("generate", ("x",), {"min_length": 3, "max_length": 2}),
149
+ ("extract", ("x",), {}), ("extract", ("x",), {"source_field": "text", "max_length_per_item": 0}),
150
+ ("choice", ("x", {}), {}), ("score", ("x", []), {}),
151
+ ):
152
+ with self.subTest(operation=operation, options=options), self.assertRaises((ValueError, TypeError)):
153
+ normalize_call("q", operation, args, options, context)
154
+
155
+ def test_response_contracts(self):
156
+ context = InferenceContext("原文")
157
+ cases = [
158
+ ("choice", ("x", {"a": "A"}), {}, ChoiceResponse("a", float("nan"), {"a": 1})),
159
+ ("generate", ("x",), {"min_length":1,"max_length": 1}, GenerateResponse("")),
160
+ ("generate_list", ("x",), {"min_items": 1,"max_items":2,"max_length_per_item":4}, GenerateListResponse([])),
161
+ ("extract", ("x",), {"max_items":2,"max_length_per_item":4}, ExtractResponse(["原 文"])),
162
+ ]
163
+ for op, args, kwargs, response in cases:
164
+ with self.assertRaises(InferenceError):
165
+ validate_response(normalize_call("q", op, args, kwargs, context), response)
166
+
167
+ def test_explicit_environment_precedence_and_no_shell_execution(self):
168
+ with tempfile.TemporaryDirectory() as directory:
169
+ path = Path(directory) / "env"
170
+ path.write_text('\n'.join(f'export ANNO_{prefix}_{suffix}="{value}"' for prefix in ("DECISION", "GENERATE") for suffix, value in (("BASE_URL", "https://example.invalid/v1"), ("AUTH_TOKEN", "$(never-execute)"), ("MODEL", "file-model"))))
171
+ with patch.dict(os.environ, {"ANNO_GENERATE_MODEL": "env-model"}, clear=True):
172
+ decision, llm = backends_from_env(env_file=path)
173
+ self.assertEqual(decision.model, "file-model")
174
+ self.assertEqual(llm.model, "env-model")
175
+ self.assertEqual(llm._token, "$(never-execute)")
176
+ with patch.dict(os.environ, {}, clear=True), self.assertRaisesRegex(ValueError, "ANNO_DECISION_BASE_URL"):
177
+ backends_from_env()
178
+
179
+
180
+ if __name__ == "__main__":
181
+ unittest.main()
@@ -0,0 +1,349 @@
1
+ """Behavioral checks for result-stream ownership and terminal outcomes."""
2
+
3
+ from collections.abc import Iterable
4
+ import json
5
+ import unittest
6
+
7
+ from annoscript.client import (
8
+ InputValidationError, AnnoClient, Result, ResultError, ResultStream,
9
+ precondition, require, system_prompt,
10
+ )
11
+
12
+
13
+ class ScriptedStream(ResultStream[str]):
14
+ def __init__(
15
+ self,
16
+ events: Iterable[Result[str] | BaseException] = (),
17
+ *,
18
+ cleanup_error: Exception | None = None,
19
+ ) -> None:
20
+ super().__init__()
21
+ self.events = iter(events)
22
+ self.cleanup_error = cleanup_error
23
+ self.pulls = 0
24
+ self.cleanups = 0
25
+
26
+ def _next_result(self) -> Result[str]:
27
+ self.pulls += 1
28
+ event = next(self.events)
29
+ if isinstance(event, BaseException):
30
+ raise event
31
+ return event
32
+
33
+ def _close(self) -> None:
34
+ self.cleanups += 1
35
+ if self.cleanup_error is not None:
36
+ raise self.cleanup_error
37
+
38
+
39
+ class ResultStreamTests(unittest.TestCase):
40
+ def test_exhaustion_closes_nonempty_and_empty_streams(self):
41
+ result = Result(index=0, status="success", value="done")
42
+ for events in ([], [result]):
43
+ with self.subTest(events=events):
44
+ stream = ScriptedStream(events)
45
+ self.assertIs(iter(stream), stream)
46
+ self.assertEqual(list(stream), events)
47
+ self.assertTrue(stream.closed)
48
+ self.assertEqual(stream.cleanups, 1)
49
+
50
+ def test_explicit_close_is_idempotent_and_prevents_further_pulls(self):
51
+ stream = ScriptedStream([Result(index=0, status="success", value="unused")])
52
+ stream.close()
53
+ stream.close()
54
+ self.assertEqual(list(stream), [])
55
+ self.assertTrue(stream.closed)
56
+ self.assertEqual(stream.pulls, 0)
57
+ self.assertEqual(stream.cleanups, 1)
58
+
59
+ def test_context_closes_after_early_break_without_consuming_remainder(self):
60
+ first = Result(index=0, status="success", value="first")
61
+ second = Result(index=1, status="success", value="second")
62
+ source = iter([first, second])
63
+ stream = ScriptedStream(source)
64
+ with stream as entered:
65
+ self.assertIs(entered, stream)
66
+ for result in entered:
67
+ self.assertIs(result, first)
68
+ break
69
+ self.assertFalse(stream.closed)
70
+ self.assertTrue(stream.closed)
71
+ self.assertEqual(stream.cleanups, 1)
72
+ self.assertEqual(stream.pulls, 1)
73
+ self.assertIs(next(source), second)
74
+ self.assertEqual(list(stream), [])
75
+
76
+ def test_context_exits_without_double_cleanup_after_exhaustion(self):
77
+ stream = ScriptedStream()
78
+ with stream:
79
+ self.assertEqual(list(stream), [])
80
+ self.assertEqual(stream.cleanups, 1)
81
+
82
+ def test_consumer_exception_propagates_and_closes(self):
83
+ error = ValueError("consumer failed")
84
+ stream = ScriptedStream()
85
+ with self.assertRaises(ValueError) as caught:
86
+ with stream:
87
+ raise error
88
+ self.assertIs(caught.exception, error)
89
+ self.assertTrue(stream.closed)
90
+ self.assertEqual(stream.cleanups, 1)
91
+
92
+ def test_iteration_failure_and_interrupt_propagate_and_close(self):
93
+ for error in (ConnectionError("connection lost"), KeyboardInterrupt()):
94
+ with self.subTest(error=type(error).__name__):
95
+ stream = ScriptedStream([error])
96
+ with self.assertRaises(type(error)) as caught:
97
+ next(stream)
98
+ self.assertIs(caught.exception, error)
99
+ self.assertTrue(stream.closed)
100
+ self.assertEqual(stream.cleanups, 1)
101
+ self.assertEqual(list(stream), [])
102
+ self.assertEqual(stream.pulls, 1)
103
+
104
+ def test_per_input_error_does_not_stop_following_results(self):
105
+ failed = Result[str](
106
+ index=0,
107
+ status="error",
108
+ error=ResultError(code="constraint_unsatisfied", message="No valid label"),
109
+ )
110
+ succeeded = Result(index=1, status="success", value="done")
111
+ stream = ScriptedStream([failed, succeeded])
112
+ with stream:
113
+ self.assertIs(next(stream), failed)
114
+ self.assertFalse(stream.closed)
115
+ self.assertIs(next(stream), succeeded)
116
+ self.assertEqual(stream.cleanups, 1)
117
+
118
+ def test_cleanup_failure_still_leaves_stream_closed(self):
119
+ error = OSError("cleanup failed")
120
+ stream = ScriptedStream(cleanup_error=error)
121
+ with self.assertRaises(OSError) as caught:
122
+ stream.close()
123
+ self.assertIs(caught.exception, error)
124
+ self.assertTrue(stream.closed)
125
+ stream.close()
126
+ self.assertEqual(list(stream), [])
127
+ self.assertEqual(stream.cleanups, 1)
128
+
129
+ def test_closed_stream_cannot_be_reentered(self):
130
+ stream = ScriptedStream()
131
+ stream.close()
132
+ with self.assertRaises(RuntimeError):
133
+ with stream:
134
+ self.fail("A closed stream must not enter the context body")
135
+ self.assertEqual(stream.cleanups, 1)
136
+
137
+
138
+ class ResultTests(unittest.TestCase):
139
+ def test_success_preserves_none_and_list_values(self):
140
+ null_result = Result(index=0, status="success", value=None)
141
+ self.assertEqual(null_result.status, "success")
142
+ self.assertIsNone(null_result.error)
143
+ value = ["a", "b"]
144
+ list_result = Result(index=1, status="success", value=value)
145
+ self.assertIs(list_result.value, value)
146
+
147
+ def test_rejects_invalid_indices_and_contradictory_outcomes(self):
148
+ error = ResultError(code="failed", message="Failed")
149
+ cases = [
150
+ {"index": -1, "status": "success"},
151
+ {"index": True, "status": "success"},
152
+ {"index": 0, "status": "pending"},
153
+ {"index": 0, "status": "success", "error": error},
154
+ {"index": 0, "status": "error"},
155
+ {"index": 0, "status": "error", "error": error, "value": "unexpected"},
156
+ ]
157
+ for options in cases:
158
+ with self.subTest(options=options):
159
+ with self.assertRaises(ValueError):
160
+ Result(**options)
161
+
162
+
163
+ class SystemPromptTests(unittest.TestCase):
164
+ def test_registration_preserves_function_and_prompt_text(self):
165
+ calls = []
166
+
167
+ def program(anno):
168
+ calls.append(anno)
169
+ return "done"
170
+
171
+ original_code = program.__code__
172
+ original_attributes = vars(program).copy()
173
+ prompt = ' 公共规则\n保留 "原文"。 '
174
+ decorated = system_prompt(prompt)(program)
175
+ self.assertIs(decorated, program)
176
+ self.assertIs(decorated.__code__, original_code)
177
+ self.assertEqual(vars(decorated), original_attributes)
178
+ self.assertEqual(AnnoClient._get_program_config(decorated), {"system_prompt": prompt})
179
+ self.assertEqual(calls, [])
180
+ self.assertEqual(decorated(None), "done")
181
+ self.assertEqual(calls, [None])
182
+
183
+ def test_duplicate_declarations_fail_without_replacing_original(self):
184
+ for prompt in ("rules", ""):
185
+ with self.subTest(prompt=prompt):
186
+ @system_prompt(prompt)
187
+ def program(anno):
188
+ return "unused"
189
+
190
+ for repeated in (prompt, "replacement"):
191
+ with self.assertRaisesRegex(ValueError, "only one"):
192
+ system_prompt(repeated)(program)
193
+ self.assertEqual(AnnoClient._get_program_config(program), {"system_prompt": prompt})
194
+
195
+ def test_both_decorator_orders_export_prompt_and_keep_checks_local(self):
196
+ for prompt_outermost in (True, False):
197
+ with self.subTest(prompt_outermost=prompt_outermost):
198
+ checked = []
199
+
200
+ def check(state):
201
+ checked.append(state)
202
+ require(isinstance(state, str), message="expected text")
203
+
204
+ def program(anno):
205
+ self.fail("Metadata and validation must not execute the program")
206
+
207
+ if prompt_outermost:
208
+ decorated = system_prompt("rules")(precondition(check)(program))
209
+ else:
210
+ decorated = precondition(check)(system_prompt("rules")(program))
211
+ config = AnnoClient._get_program_config(decorated)
212
+ self.assertEqual(json.loads(json.dumps(config)), {"system_prompt": "rules"})
213
+ self.assertEqual(checked, [])
214
+ self.assertIs(decorated, program)
215
+ errors = AnnoClient._validate_inputs(decorated, ["valid", {}])
216
+ self.assertEqual(checked, ["valid", {}])
217
+ self.assertEqual(set(errors), {1})
218
+ self.assertEqual(errors[1].code, "invalid_input")
219
+
220
+ def test_configuration_is_isolated_between_programs_and_reads(self):
221
+ @system_prompt("first rules")
222
+ def first(anno):
223
+ return "unused"
224
+
225
+ @system_prompt("second rules")
226
+ def second(anno):
227
+ return "unused"
228
+
229
+ def undecorated(anno):
230
+ return "unused"
231
+
232
+ config = AnnoClient._get_program_config(first)
233
+ config["system_prompt"] = "changed"
234
+ self.assertEqual(AnnoClient._get_program_config(first), {"system_prompt": "first rules"})
235
+ self.assertEqual(AnnoClient._get_program_config(second), {"system_prompt": "second rules"})
236
+ self.assertEqual(AnnoClient._get_program_config(undecorated), {})
237
+
238
+ def test_prompt_must_be_text(self):
239
+ for prompt in (None, 1, [], {}):
240
+ with self.subTest(prompt=prompt):
241
+ with self.assertRaisesRegex(TypeError, "requires a string"):
242
+ system_prompt(prompt)
243
+
244
+
245
+ class PreconditionTests(unittest.TestCase):
246
+ def test_require_reports_invalid_input(self):
247
+ self.assertIsNone(require(True, message="unused"))
248
+ with self.assertRaisesRegex(InputValidationError, "text is required"):
249
+ require(False, message="text is required")
250
+
251
+ def test_registration_preserves_function_and_does_not_run_checks(self):
252
+ checked = []
253
+
254
+ def check(state):
255
+ checked.append(state)
256
+
257
+ def program(anno):
258
+ return "done"
259
+
260
+ original_attributes = vars(program).copy()
261
+ original_code = program.__code__
262
+ decorated = precondition(check)(program)
263
+ self.assertIs(decorated, program)
264
+ self.assertIs(decorated.__code__, original_code)
265
+ self.assertEqual(vars(decorated), original_attributes)
266
+ self.assertEqual(decorated(None), "done")
267
+ self.assertEqual(checked, [])
268
+ self.assertEqual(AnnoClient._validate_inputs(decorated, ["record"]), {})
269
+ self.assertEqual(checked, ["record"])
270
+
271
+ def test_local_batch_check_preserves_indices_and_never_runs_program(self):
272
+ checked = []
273
+
274
+ def check(state):
275
+ checked.append(state)
276
+ require(isinstance(state, str), message="expected text")
277
+
278
+ @precondition(check)
279
+ def program(anno):
280
+ self.fail("Preconditions must not execute the program")
281
+
282
+ inputs = [{}, "valid", []]
283
+ errors = AnnoClient._validate_inputs(program, inputs)
284
+ self.assertEqual(checked, inputs)
285
+ self.assertEqual(errors, {
286
+ 0: ResultError(code="invalid_input", message="expected text"),
287
+ 2: ResultError(code="invalid_input", message="expected text"),
288
+ })
289
+
290
+ def test_stacked_checks_run_in_source_order_and_stop_on_invalid_input(self):
291
+ checked = []
292
+
293
+ def check_object(state):
294
+ checked.append("object")
295
+ require(isinstance(state, dict), message="expected object")
296
+
297
+ def check_text(state):
298
+ checked.append("text")
299
+ require(isinstance(state.get("text"), str), message="expected text")
300
+
301
+ @precondition(check_object)
302
+ @precondition(check_text)
303
+ def program(anno):
304
+ return "unused"
305
+
306
+ errors = AnnoClient._validate_inputs(program, ["wrong", {}, {"text": "valid"}])
307
+ self.assertEqual(set(errors), {0, 1})
308
+ self.assertEqual(checked, ["object", "object", "text", "object", "text"])
309
+
310
+ def test_unexpected_checker_errors_abort_validation(self):
311
+ for error in (ValueError("checker bug"), KeyError("missing")):
312
+ with self.subTest(error=type(error).__name__):
313
+ checked = []
314
+
315
+ def check(state):
316
+ checked.append(state)
317
+ raise error
318
+
319
+ @precondition(check)
320
+ def program(anno):
321
+ return "unused"
322
+
323
+ with self.assertRaises(type(error)) as caught:
324
+ AnnoClient._validate_inputs(program, ["first", "second"])
325
+ self.assertIs(caught.exception, error)
326
+ self.assertEqual(checked, ["first"])
327
+
328
+ def test_boolean_checker_return_is_a_defect_not_a_validation_result(self):
329
+ @precondition(lambda state: False)
330
+ def program(anno):
331
+ return "unused"
332
+
333
+ with self.assertRaisesRegex(TypeError, "return None"):
334
+ AnnoClient._validate_inputs(program, ["record"])
335
+
336
+ def test_empty_batch_and_program_without_preconditions(self):
337
+ def check(state):
338
+ self.fail("An empty batch must not run checks")
339
+
340
+ def program(anno):
341
+ self.fail("Validation must not run the program")
342
+
343
+ self.assertEqual(AnnoClient._validate_inputs(program, ["record"]), {})
344
+ precondition(check)(program)
345
+ self.assertEqual(AnnoClient._validate_inputs(program, []), {})
346
+
347
+
348
+ if __name__ == "__main__":
349
+ unittest.main()
@@ -0,0 +1,95 @@
1
+ """Compact wire aliases and strict values, independent of fixture serialization."""
2
+
3
+ import json
4
+ import unittest
5
+
6
+ import httpx
7
+
8
+ from annoscript.backends import InferenceContext, LLMAPIBackend, InferenceError
9
+ from annoscript.backends.base import normalize_call
10
+
11
+
12
+ class CompactBatchTests(unittest.IsolatedAsyncioTestCase):
13
+ def setup_calls(self):
14
+ context = InferenceContext({"text": '甲"\\\n😀', "other": "乙"})
15
+ calls = [
16
+ normalize_call("q2", "generate", ("标题",), {"min_length": 1, "max_length": 1}, context),
17
+ normalize_call('run9-"\n😀', "extract", ("抽取",),
18
+ {"source_field": "text", "min_items": 1, "max_items": 1, "max_length_per_item": 1}, context),
19
+ normalize_call("run9-q2", "generate_list", ("空列表",),
20
+ {"max_items": 0, "max_length_per_item": 0}, context),
21
+ ]
22
+ return context, calls
23
+
24
+ async def invoke(self, profile, content, context, calls):
25
+ requests, events = [], []
26
+ def handle(request):
27
+ requests.append(json.loads(request.content))
28
+ return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": content}}]})
29
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile,
30
+ transport=httpx.MockTransport(handle), diagnostic_observer=events.append)
31
+ try:
32
+ result = await backend.infer_batch(context, calls, max_tokens=512)
33
+ return result, requests, events
34
+ finally:
35
+ await backend.aclose()
36
+
37
+ async def test_aliases_restore_original_ids_and_accept_reordered_answers(self):
38
+ context, calls = self.setup_calls()
39
+ for profile in ("legacy", "qwen_vllm"):
40
+ content = json.dumps({"answers": {"q3": [], "q2": [context.state["text"]], "q1": "超出建议长度"}}, ensure_ascii=False)
41
+ result, requests, events = await self.invoke(profile, content, context, calls)
42
+ self.assertEqual(result.errors, {})
43
+ self.assertEqual(set(result.responses), {call.id for call in calls})
44
+ self.assertEqual(result.responses["q2"].text, "超出建议长度")
45
+ self.assertEqual(result.responses[calls[1].id].items, [context.state["text"]])
46
+ system = requests[0]["messages"][0]["content"]
47
+ for index in (1, 2, 3):
48
+ self.assertIn(f'任务 "q{index}"', system)
49
+ self.assertNotIn("run9", system)
50
+ self.assertEqual(json.loads(requests[0]["messages"][1]["content"]), dict(context.state))
51
+ mapping = {"q1": "q2", "q2": calls[1].id, "q3": "run9-q2"}
52
+ self.assertTrue(any(event.get("wire_call_ids") == mapping for event in events))
53
+ self.assertTrue(any(event.get("batch_response_format") == "short_ids_values_v1" for event in events))
54
+
55
+ async def test_old_wrappers_and_wrong_value_types_are_local_errors(self):
56
+ context, calls = self.setup_calls()
57
+ for profile in ("legacy", "qwen_vllm"):
58
+ for value in ({"type": "generate", "text": "甲"}, {"text": "甲"}, [], None, 1, True):
59
+ with self.subTest(profile=profile, value=value):
60
+ content = json.dumps({"answers": {"q1": value, "q2": ["甲"], "q3": []}})
61
+ result, _, _ = await self.invoke(profile, content, context, calls)
62
+ self.assertEqual(set(result.errors), {calls[0].id})
63
+ self.assertEqual(result.errors[calls[0].id].call_id, calls[0].id)
64
+ self.assertEqual(set(result.responses), {calls[1].id, calls[2].id})
65
+ for value in ("甲", {"type": "extract", "items": ["甲"]}, [None], ["乙"], [""], ["甲", "甲"]):
66
+ content = json.dumps({"answers": {"q1": "标题", "q2": value, "q3": []}})
67
+ result, _, _ = await self.invoke(profile, content, context, calls)
68
+ self.assertEqual(set(result.errors), {calls[1].id})
69
+
70
+ async def test_invalid_envelope_is_not_guessed_or_partially_adopted(self):
71
+ context, calls = self.setup_calls()
72
+ bad = ['{"answers":{"q1":"a","q1":"b"}}', '{"answers":{},"answers":{}}',
73
+ '{"answers":{"run9-q2":[]}}', '{"answers":{"q0":[]}}',
74
+ '{"answers":{"q4":[]}}', '{"answers":[],"extra":0}', '[]', '{}']
75
+ for profile in ("legacy", "qwen_vllm"):
76
+ for content in bad:
77
+ with self.subTest(profile=profile, content=content), self.assertRaises(InferenceError) as caught:
78
+ await self.invoke(profile, content, context, calls)
79
+ self.assertEqual(caught.exception.code, "invalid_batch_output")
80
+
81
+ async def test_subsets_restart_aliases_without_reusing_a_different_binding(self):
82
+ context, calls = self.setup_calls()
83
+ for profile in ("legacy", "qwen_vllm"):
84
+ backend = LLMAPIBackend("http://offline.invalid", "fixture", "fixture", profile=profile)
85
+ try:
86
+ first = backend._prepare(context, calls[:2], 512, batch=True)
87
+ second = backend._prepare(context, calls[1:], 512, batch=True)
88
+ self.assertNotEqual(first.binding, second.binding)
89
+ self.assertEqual(dict(first.evidence["wire_call_ids"]), {"q1": calls[0].id, "q2": calls[1].id})
90
+ self.assertEqual(dict(second.evidence["wire_call_ids"]), {"q1": calls[1].id, "q2": calls[2].id})
91
+ result, _, _ = await self.invoke(profile, '{"answers":{"q1":["甲"],"q2":[]}}', context, calls[1:])
92
+ self.assertEqual(set(result.responses), {calls[1].id, calls[2].id})
93
+ self.assertEqual(result.errors, {})
94
+ finally:
95
+ await backend.aclose()