|
25 | 25 | __all__ = ["CompositeSelectorParser"] |
26 | 26 |
|
27 | 27 | _WEIGHT_PREFIX_PATTERN = re.compile(r"^\[weight=([^\]]+)\](.*)$") |
| 28 | +_FIRST_PREFIX_PATTERN = re.compile(r"^\[first(?:=(true|yes|1))?\](.*)$", re.IGNORECASE) |
| 29 | +_FIRST_NEGATIVE_PREFIX_PATTERN = re.compile( |
| 30 | + r"^\[first=(false|no|0)\](.*)$", re.IGNORECASE |
| 31 | +) |
28 | 32 | _INTEGER_PATTERN = re.compile(r"^[+-]?\d+$") |
29 | 33 |
|
30 | 34 |
|
@@ -103,6 +107,13 @@ def parse(self, routing_input: CompositeRoutingInput) -> CompositeRoutePlan: |
103 | 107 | ) |
104 | 108 | for segment in parts |
105 | 109 | ] |
| 110 | + first_count = sum(1 for leaf in leaves if leaf.leaf.first_annotation) |
| 111 | + if first_count > 1: |
| 112 | + self._raise_validation_error( |
| 113 | + code=CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT, |
| 114 | + selector=routing_input.selector, |
| 115 | + message="Only one branch can have a [first] annotation in a weighted group.", |
| 116 | + ) |
106 | 117 | normalized = "^".join(leaf.normalized_leaf_for_plan for leaf in leaves) |
107 | 118 | return CompositeRoutePlan( |
108 | 119 | source_selector=routing_input.selector, |
@@ -182,25 +193,51 @@ def _parse_leaf( |
182 | 193 | ) |
183 | 194 |
|
184 | 195 | weight_annotation: int | None = None |
| 196 | + first_annotation: bool = False |
185 | 197 | normalized_leaf_selector = raw_leaf_text |
186 | 198 | normalized_leaf_for_plan = raw_leaf_text |
187 | 199 |
|
188 | 200 | if is_weighted_group: |
189 | | - weight_annotation, normalized_leaf_selector = self._extract_weight_prefix( |
190 | | - raw_leaf_text, |
| 201 | + remaining = raw_leaf_text |
| 202 | + weight_annotation = None |
| 203 | + first_annotation = False |
| 204 | + |
| 205 | + # Extract first prefix regardless of order (could be [first] or [weight=N]) |
| 206 | + first_annotation, remaining = self._extract_first_prefix( |
| 207 | + remaining, |
191 | 208 | source_selector=routing_input.selector, |
192 | 209 | ) |
| 210 | + weight_annotation, remaining = self._extract_weight_prefix( |
| 211 | + remaining, |
| 212 | + source_selector=routing_input.selector, |
| 213 | + ) |
| 214 | + |
| 215 | + # If first pass didn't find [first], try after extracting [weight] |
| 216 | + if not first_annotation: |
| 217 | + first_annotation, remaining = self._extract_first_prefix( |
| 218 | + remaining, |
| 219 | + source_selector=routing_input.selector, |
| 220 | + ) |
| 221 | + |
| 222 | + normalized_leaf_selector = remaining |
193 | 223 | if weight_annotation is None: |
194 | 224 | weight_annotation = 1 |
195 | | - normalized_leaf_for_plan = ( |
196 | | - f"[weight={weight_annotation}]{normalized_leaf_selector}" |
197 | | - ) |
| 225 | + prefix_parts = "" |
| 226 | + prefix_parts += f"[weight={weight_annotation}]" |
| 227 | + prefix_parts += "[first]" if first_annotation else "" |
| 228 | + normalized_leaf_for_plan = f"{prefix_parts}{normalized_leaf_selector}" |
198 | 229 | elif raw_leaf_text.startswith("[weight="): |
199 | 230 | self._raise_validation_error( |
200 | 231 | code=CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT, |
201 | 232 | selector=routing_input.selector, |
202 | 233 | message="Weight annotations are only supported for weighted ('^') selectors.", |
203 | 234 | ) |
| 235 | + elif "[first" in raw_leaf_text.lower() and raw_leaf_text.startswith("[first"): |
| 236 | + self._raise_validation_error( |
| 237 | + code=CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT, |
| 238 | + selector=routing_input.selector, |
| 239 | + message="First annotations are only supported for weighted ('^') selectors.", |
| 240 | + ) |
204 | 241 |
|
205 | 242 | parsed_leaf = parse_model_with_params( |
206 | 243 | normalized_leaf_selector, |
@@ -242,6 +279,7 @@ def _parse_leaf( |
242 | 279 | raw_selector=raw_leaf_text, |
243 | 280 | normalized_selector=normalized_leaf_selector, |
244 | 281 | weight_annotation=weight_annotation if is_weighted_group else None, |
| 282 | + first_annotation=first_annotation if is_weighted_group else False, |
245 | 283 | uri_params=parsed_leaf.uri_params, |
246 | 284 | backend_type=parsed_leaf.backend_type, |
247 | 285 | model_name=parsed_leaf.model_name, |
@@ -296,6 +334,37 @@ def _extract_weight_prefix( |
296 | 334 |
|
297 | 335 | return weight_value, trailing_selector.strip() |
298 | 336 |
|
| 337 | + def _extract_first_prefix( |
| 338 | + self, |
| 339 | + leaf_text: str, |
| 340 | + *, |
| 341 | + source_selector: str, |
| 342 | + ) -> tuple[bool, str]: |
| 343 | + # Reject negative forms like [first=false], [first=0], [first=no] |
| 344 | + neg_match = _FIRST_NEGATIVE_PREFIX_PATTERN.match(leaf_text) |
| 345 | + if neg_match: |
| 346 | + self._raise_validation_error( |
| 347 | + code=CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT, |
| 348 | + selector=source_selector, |
| 349 | + message=f"Unsupported [first] annotation '{neg_match.group(0)}'. Use [first] without negation.", |
| 350 | + ) |
| 351 | + |
| 352 | + match = _FIRST_PREFIX_PATTERN.match(leaf_text) |
| 353 | + if not match: |
| 354 | + return False, leaf_text |
| 355 | + |
| 356 | + trailing_selector = match.group(2) or "" |
| 357 | + if not trailing_selector or trailing_selector[0].isspace(): |
| 358 | + self._raise_validation_error( |
| 359 | + code=CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT, |
| 360 | + selector=source_selector, |
| 361 | + message=( |
| 362 | + "[first] must appear immediately before a selector without whitespace." |
| 363 | + ), |
| 364 | + ) |
| 365 | + |
| 366 | + return True, trailing_selector.strip() |
| 367 | + |
299 | 368 | @staticmethod |
300 | 369 | def _raise_validation_error( |
301 | 370 | *, |
|
0 commit comments