cu-cli-core 0.1.0b1__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.
- cu_cli_core/__init__.py +12 -0
- cu_cli_core/analysis.py +374 -0
- cu_cli_core/client.py +39 -0
- cu_cli_core/command_spec.py +1099 -0
- cu_cli_core/contracts.py +404 -0
- cu_cli_core/defaults.py +150 -0
- cu_cli_core/environment.py +102 -0
- cu_cli_core/errors.py +77 -0
- cu_cli_core/input_planning.py +322 -0
- cu_cli_core/operations/__init__.py +4 -0
- cu_cli_core/operations/analysis.py +294 -0
- cu_cli_core/operations/analyzer_copy.py +327 -0
- cu_cli_core/operations/analyzers.py +85 -0
- cu_cli_core/operations/profiles.py +146 -0
- cu_cli_core/operations/schema.py +48 -0
- cu_cli_core/operations/validation.py +41 -0
- cu_cli_core/profiles.py +604 -0
- cu_cli_core/py.typed +0 -0
- cu_cli_core/resources/openapi/2025-11-01/ContentUnderstanding.json +3643 -0
- cu_cli_core/resources/openapi/2026-06-01-preview/ContentUnderstanding.json +4050 -0
- cu_cli_core/schema.py +389 -0
- cu_cli_core/schema_validation.py +312 -0
- cu_cli_core/serialization.py +75 -0
- cu_cli_core/service_options.py +71 -0
- cu_cli_core/spec_validation.py +141 -0
- cu_cli_core-0.1.0b1.dist-info/METADATA +26 -0
- cu_cli_core-0.1.0b1.dist-info/RECORD +29 -0
- cu_cli_core-0.1.0b1.dist-info/WHEEL +5 -0
- cu_cli_core-0.1.0b1.dist-info/top_level.txt +1 -0
cu_cli_core/errors.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Frontend-neutral CU error types."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ErrorCategory(str, Enum):
|
|
14
|
+
USAGE = "usage"
|
|
15
|
+
VALIDATION = "validation"
|
|
16
|
+
AUTHENTICATION = "authentication"
|
|
17
|
+
NOT_FOUND = "not-found"
|
|
18
|
+
CONFLICT = "conflict"
|
|
19
|
+
SERVICE = "service"
|
|
20
|
+
LOCAL_IO = "local-io"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ServiceErrorDetail:
|
|
25
|
+
code: str | None = None
|
|
26
|
+
message: str | None = None
|
|
27
|
+
target: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CuCoreError(Exception):
|
|
31
|
+
"""Structured failure translated by each command frontend."""
|
|
32
|
+
|
|
33
|
+
category = ErrorCategory.SERVICE
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
hint: str | None = None,
|
|
40
|
+
status_code: int | None = None,
|
|
41
|
+
details: tuple[ServiceErrorDetail, ...] = (),
|
|
42
|
+
context: Mapping[str, Any] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.message = message
|
|
46
|
+
self.hint = hint
|
|
47
|
+
self.status_code = status_code
|
|
48
|
+
self.details = details
|
|
49
|
+
self.context = dict(context or {})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UsageError(CuCoreError):
|
|
53
|
+
category = ErrorCategory.USAGE
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ValidationError(CuCoreError):
|
|
57
|
+
category = ErrorCategory.VALIDATION
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthenticationError(CuCoreError):
|
|
61
|
+
category = ErrorCategory.AUTHENTICATION
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotFoundError(CuCoreError):
|
|
65
|
+
category = ErrorCategory.NOT_FOUND
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ConflictError(CuCoreError):
|
|
69
|
+
category = ErrorCategory.CONFLICT
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ServiceError(CuCoreError):
|
|
73
|
+
category = ErrorCategory.SERVICE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LocalIOError(CuCoreError):
|
|
77
|
+
category = ErrorCategory.LOCAL_IO
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
from .contracts import (
|
|
14
|
+
ExecutionPlan,
|
|
15
|
+
ExistingResultPolicy,
|
|
16
|
+
InputOrigin,
|
|
17
|
+
InputPlan,
|
|
18
|
+
PlannedInput,
|
|
19
|
+
PlannedOutput,
|
|
20
|
+
ResultView,
|
|
21
|
+
SelectionMode,
|
|
22
|
+
SkippedInput,
|
|
23
|
+
)
|
|
24
|
+
from .errors import UsageError, ValidationError
|
|
25
|
+
|
|
26
|
+
_WILDCARD_CHARS = frozenset("*?[")
|
|
27
|
+
_RESULT_SUFFIX = {
|
|
28
|
+
ResultView.LLM_INPUT: ".result.md",
|
|
29
|
+
ResultView.FULL: ".result.json",
|
|
30
|
+
}
|
|
31
|
+
_GENERATED_RESULT_SUFFIXES = tuple(_RESULT_SUFFIX.values())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _reject_direct_duplicates(values: Sequence[str | Path], option: str) -> None:
|
|
35
|
+
rendered = [os.fspath(value) for value in values]
|
|
36
|
+
duplicates = sorted(value for value, count in Counter(rendered).items() if count > 1)
|
|
37
|
+
if duplicates:
|
|
38
|
+
joined = ", ".join(duplicates)
|
|
39
|
+
raise UsageError(f"{option} was provided more than once for: {joined}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _file_identity(path: Path) -> object:
|
|
43
|
+
stat = path.stat()
|
|
44
|
+
if stat.st_ino:
|
|
45
|
+
return (stat.st_dev, stat.st_ino)
|
|
46
|
+
return os.path.normcase(os.fspath(path.resolve()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _validated_file(path: Path, *, option: str) -> tuple[Path, int]:
|
|
50
|
+
if not path.exists():
|
|
51
|
+
raise ValidationError(f"{option} does not exist: {path}")
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
raise ValidationError(f"{option} must identify a file: {path}")
|
|
54
|
+
try:
|
|
55
|
+
stat = path.stat()
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise ValidationError(f"{option} cannot be read: {path}") from exc
|
|
58
|
+
return path.resolve(), stat.st_size
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validated_source(path: Path, *, option: str) -> Path:
|
|
62
|
+
if not path.exists():
|
|
63
|
+
raise ValidationError(f"{option} does not exist: {path}")
|
|
64
|
+
if not path.is_dir():
|
|
65
|
+
raise ValidationError(f"{option} must identify a directory: {path}")
|
|
66
|
+
return path.resolve()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _directory_files(
|
|
70
|
+
source: Path,
|
|
71
|
+
*,
|
|
72
|
+
recursive: bool,
|
|
73
|
+
pattern: str,
|
|
74
|
+
skipped: dict[Path, SkippedInput],
|
|
75
|
+
) -> Iterable[Path]:
|
|
76
|
+
candidates = source.rglob("*") if recursive else source.iterdir()
|
|
77
|
+
files = (path for path in candidates if path.is_file())
|
|
78
|
+
selected: list[Path] = []
|
|
79
|
+
for path in files:
|
|
80
|
+
relative_path = path.relative_to(source)
|
|
81
|
+
if not fnmatch(relative_path.as_posix(), pattern):
|
|
82
|
+
continue
|
|
83
|
+
if path.name.endswith(_GENERATED_RESULT_SUFFIXES):
|
|
84
|
+
continue
|
|
85
|
+
if any(part.startswith(".") for part in relative_path.parts[:-1]):
|
|
86
|
+
continue
|
|
87
|
+
if path.name.startswith("."):
|
|
88
|
+
skipped.setdefault(
|
|
89
|
+
path,
|
|
90
|
+
SkippedInput(path=path, reason="hidden file skipped"),
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
selected.append(path)
|
|
94
|
+
return sorted(selected, key=lambda path: path.relative_to(source).as_posix())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def plan_inputs(
|
|
98
|
+
*,
|
|
99
|
+
positional: Sequence[str | Path] = (),
|
|
100
|
+
files: Sequence[str | Path] = (),
|
|
101
|
+
sources: Sequence[str | Path] = (),
|
|
102
|
+
pattern: str | None = None,
|
|
103
|
+
recursive: bool = False,
|
|
104
|
+
) -> InputPlan:
|
|
105
|
+
"""Validate and expand one invocation's local input selection."""
|
|
106
|
+
if positional and (files or sources):
|
|
107
|
+
conflicts = []
|
|
108
|
+
if files:
|
|
109
|
+
conflicts.append("--file")
|
|
110
|
+
if sources:
|
|
111
|
+
conflicts.append("--source")
|
|
112
|
+
raise UsageError(
|
|
113
|
+
"positional inputs cannot be combined with " + " or ".join(conflicts) + "."
|
|
114
|
+
)
|
|
115
|
+
if files and sources:
|
|
116
|
+
raise UsageError("--file and --source cannot be combined.")
|
|
117
|
+
if pattern is not None and not sources:
|
|
118
|
+
raise UsageError("--pattern is valid only with --source.")
|
|
119
|
+
if not positional and not files and not sources:
|
|
120
|
+
raise UsageError("provide positional inputs, --file, or --source.")
|
|
121
|
+
|
|
122
|
+
if positional:
|
|
123
|
+
mode = SelectionMode.POSITIONAL
|
|
124
|
+
direct = positional
|
|
125
|
+
option = "positional input"
|
|
126
|
+
elif files:
|
|
127
|
+
mode = SelectionMode.NAMED_FILES
|
|
128
|
+
direct = files
|
|
129
|
+
option = "--file"
|
|
130
|
+
else:
|
|
131
|
+
mode = SelectionMode.NAMED_SOURCES
|
|
132
|
+
direct = sources
|
|
133
|
+
option = "--source"
|
|
134
|
+
_reject_direct_duplicates(direct, option)
|
|
135
|
+
|
|
136
|
+
selected: list[PlannedInput] = []
|
|
137
|
+
seen: set[object] = set()
|
|
138
|
+
skipped: dict[Path, SkippedInput] = {}
|
|
139
|
+
includes_directory = bool(sources)
|
|
140
|
+
|
|
141
|
+
def add_file(
|
|
142
|
+
path: Path,
|
|
143
|
+
*,
|
|
144
|
+
source_root: Path,
|
|
145
|
+
relative_path: Path,
|
|
146
|
+
origin: InputOrigin,
|
|
147
|
+
) -> None:
|
|
148
|
+
resolved, size = _validated_file(path, option=option)
|
|
149
|
+
identity = _file_identity(resolved)
|
|
150
|
+
if identity in seen:
|
|
151
|
+
return
|
|
152
|
+
seen.add(identity)
|
|
153
|
+
selected.append(
|
|
154
|
+
PlannedInput(
|
|
155
|
+
path=resolved,
|
|
156
|
+
source_root=source_root,
|
|
157
|
+
relative_path=relative_path,
|
|
158
|
+
origin=origin,
|
|
159
|
+
size_bytes=size,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if positional:
|
|
164
|
+
for value in positional:
|
|
165
|
+
text = os.fspath(value)
|
|
166
|
+
if any(char in text for char in _WILDCARD_CHARS):
|
|
167
|
+
raise UsageError(
|
|
168
|
+
f"wildcard patterns aren't accepted as positional inputs: {text}",
|
|
169
|
+
hint='Use --source with --pattern, for example: --source . --pattern "*.pdf"',
|
|
170
|
+
)
|
|
171
|
+
path = Path(value)
|
|
172
|
+
if not path.exists():
|
|
173
|
+
raise ValidationError(f"positional input does not exist: {path}")
|
|
174
|
+
if path.is_dir():
|
|
175
|
+
includes_directory = True
|
|
176
|
+
source = _validated_source(path, option=option)
|
|
177
|
+
for child in _directory_files(
|
|
178
|
+
source,
|
|
179
|
+
recursive=recursive,
|
|
180
|
+
pattern="*",
|
|
181
|
+
skipped=skipped,
|
|
182
|
+
):
|
|
183
|
+
add_file(
|
|
184
|
+
child,
|
|
185
|
+
source_root=source,
|
|
186
|
+
relative_path=child.relative_to(source),
|
|
187
|
+
origin=InputOrigin.POSITIONAL_SOURCE,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
resolved, _ = _validated_file(path, option=option)
|
|
191
|
+
add_file(
|
|
192
|
+
resolved,
|
|
193
|
+
source_root=resolved.parent,
|
|
194
|
+
relative_path=Path(resolved.name),
|
|
195
|
+
origin=InputOrigin.POSITIONAL_FILE,
|
|
196
|
+
)
|
|
197
|
+
elif files:
|
|
198
|
+
for value in files:
|
|
199
|
+
path, _ = _validated_file(Path(value), option=option)
|
|
200
|
+
add_file(
|
|
201
|
+
path,
|
|
202
|
+
source_root=path.parent,
|
|
203
|
+
relative_path=Path(path.name),
|
|
204
|
+
origin=InputOrigin.NAMED_FILE,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
effective_pattern = pattern if pattern is not None else "*"
|
|
208
|
+
if not effective_pattern:
|
|
209
|
+
raise UsageError("--pattern cannot be empty.")
|
|
210
|
+
for value in sources:
|
|
211
|
+
source = _validated_source(Path(value), option=option)
|
|
212
|
+
for child in _directory_files(
|
|
213
|
+
source,
|
|
214
|
+
recursive=recursive,
|
|
215
|
+
pattern=effective_pattern,
|
|
216
|
+
skipped=skipped,
|
|
217
|
+
):
|
|
218
|
+
add_file(
|
|
219
|
+
child,
|
|
220
|
+
source_root=source,
|
|
221
|
+
relative_path=child.relative_to(source),
|
|
222
|
+
origin=InputOrigin.NAMED_SOURCE,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if recursive and not includes_directory:
|
|
226
|
+
raise UsageError("--recursive is valid only when input selection includes a directory.")
|
|
227
|
+
if not selected:
|
|
228
|
+
if skipped:
|
|
229
|
+
details = ", ".join(
|
|
230
|
+
f"{item.path} ({item.reason})"
|
|
231
|
+
for item in skipped.values()
|
|
232
|
+
)
|
|
233
|
+
raise ValidationError(
|
|
234
|
+
"input selection did not find any analyzable files. "
|
|
235
|
+
f"Skipped during discovery: {details}."
|
|
236
|
+
)
|
|
237
|
+
raise ValidationError("input selection did not find any files.")
|
|
238
|
+
|
|
239
|
+
extension_counts = Counter(
|
|
240
|
+
item.path.suffix.lower() or "(none)" for item in selected
|
|
241
|
+
)
|
|
242
|
+
return InputPlan(
|
|
243
|
+
mode=mode,
|
|
244
|
+
inputs=tuple(selected),
|
|
245
|
+
recursive=recursive,
|
|
246
|
+
pattern=pattern,
|
|
247
|
+
total_bytes=sum(item.size_bytes for item in selected),
|
|
248
|
+
extension_counts=dict(sorted(extension_counts.items())),
|
|
249
|
+
skipped=tuple(
|
|
250
|
+
skipped[path]
|
|
251
|
+
for path in sorted(skipped, key=lambda path: os.fspath(path))
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _result_path(path: Path, view: ResultView) -> Path:
|
|
257
|
+
return Path(f"{path}{_RESULT_SUFFIX[view]}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def plan_outputs(
|
|
261
|
+
input_plan: InputPlan,
|
|
262
|
+
*,
|
|
263
|
+
view: ResultView,
|
|
264
|
+
output_file: str | Path | None = None,
|
|
265
|
+
output_dir: str | Path | None = None,
|
|
266
|
+
on_existing: ExistingResultPolicy = ExistingResultPolicy.ERROR,
|
|
267
|
+
stream_single: bool = True,
|
|
268
|
+
dry_run: bool = False,
|
|
269
|
+
) -> ExecutionPlan:
|
|
270
|
+
"""Resolve result destinations, collisions, and existing-file actions."""
|
|
271
|
+
if output_file is not None and output_dir is not None:
|
|
272
|
+
raise UsageError("--output-file and --output-dir cannot be combined.")
|
|
273
|
+
if output_file is not None and len(input_plan.inputs) != 1:
|
|
274
|
+
raise UsageError("--output-file is valid only when exactly one file is selected.")
|
|
275
|
+
|
|
276
|
+
destinations: list[Path | None] = []
|
|
277
|
+
for item in input_plan.inputs:
|
|
278
|
+
if output_file is not None:
|
|
279
|
+
destination = Path(output_file)
|
|
280
|
+
elif output_dir is not None:
|
|
281
|
+
relative = item.relative_path
|
|
282
|
+
if relative.is_absolute() or ".." in relative.parts:
|
|
283
|
+
raise ValidationError(
|
|
284
|
+
f"source-relative output path is invalid: {relative}"
|
|
285
|
+
)
|
|
286
|
+
destination = _result_path(Path(output_dir) / relative, view)
|
|
287
|
+
elif stream_single and len(input_plan.inputs) == 1:
|
|
288
|
+
destination = None
|
|
289
|
+
else:
|
|
290
|
+
destination = _result_path(item.path, view)
|
|
291
|
+
destinations.append(destination)
|
|
292
|
+
|
|
293
|
+
counts = Counter(path for path in destinations if path is not None)
|
|
294
|
+
collided = {path for path, count in counts.items() if count > 1}
|
|
295
|
+
for index, destination in enumerate(destinations):
|
|
296
|
+
if destination not in collided:
|
|
297
|
+
continue
|
|
298
|
+
digest = hashlib.sha1(
|
|
299
|
+
os.fspath(input_plan.inputs[index].path).encode("utf-8")
|
|
300
|
+
).hexdigest()[:8]
|
|
301
|
+
suffix = _RESULT_SUFFIX[view]
|
|
302
|
+
assert destination is not None
|
|
303
|
+
base = destination.name[: -len(suffix)]
|
|
304
|
+
destinations[index] = destination.with_name(f"{base}.{digest}{suffix}")
|
|
305
|
+
|
|
306
|
+
outputs: list[PlannedOutput] = []
|
|
307
|
+
for item, destination in zip(input_plan.inputs, destinations, strict=True):
|
|
308
|
+
exists = destination is not None and destination.exists()
|
|
309
|
+
outputs.append(
|
|
310
|
+
PlannedOutput(
|
|
311
|
+
source=item,
|
|
312
|
+
path=destination,
|
|
313
|
+
exists=exists,
|
|
314
|
+
skipped=exists and on_existing is ExistingResultPolicy.SKIP,
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
return ExecutionPlan(
|
|
318
|
+
input_plan=input_plan,
|
|
319
|
+
outputs=tuple(outputs),
|
|
320
|
+
on_existing=on_existing,
|
|
321
|
+
dry_run=dry_run,
|
|
322
|
+
)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT license.
|
|
3
|
+
|
|
4
|
+
"""Shared orchestration for analyze and analyzer-test commands."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from ..analysis import AnalyzeJob, AnalyzeOutcome, BatchResult, analyze_many
|
|
11
|
+
from ..contracts import AnalyzeRequest, AnalyzerTestRequest, InputPlan
|
|
12
|
+
from ..input_planning import plan_inputs
|
|
13
|
+
|
|
14
|
+
_TEST_DISCLAIMER = (
|
|
15
|
+
"This is not a real accuracy benchmark. It only checks whether a value was "
|
|
16
|
+
"extracted or generated, and reports confidence when the service returns it."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _input_plan(request: AnalyzeRequest | AnalyzerTestRequest) -> InputPlan:
|
|
21
|
+
return plan_inputs(
|
|
22
|
+
positional=request.positional_inputs,
|
|
23
|
+
files=request.files,
|
|
24
|
+
sources=request.sources,
|
|
25
|
+
pattern=request.pattern,
|
|
26
|
+
recursive=request.recursive,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def execute_analyze(
|
|
31
|
+
client: Any,
|
|
32
|
+
request: AnalyzeRequest,
|
|
33
|
+
*,
|
|
34
|
+
input_plan: InputPlan | None = None,
|
|
35
|
+
jobs: list[AnalyzeJob] | None = None,
|
|
36
|
+
on_result: Callable[[AnalyzeOutcome], None] | None = None,
|
|
37
|
+
run: Callable[[Any, AnalyzeJob], Any] | None = None,
|
|
38
|
+
) -> BatchResult:
|
|
39
|
+
"""Execute a normalized analyze request against an injected client."""
|
|
40
|
+
|
|
41
|
+
planned = input_plan or _input_plan(request)
|
|
42
|
+
selected_jobs = jobs or [
|
|
43
|
+
AnalyzeJob(
|
|
44
|
+
input_ref=str(item.path),
|
|
45
|
+
analyzer_id=request.analyzer or "",
|
|
46
|
+
out_path=None,
|
|
47
|
+
)
|
|
48
|
+
for item in planned.inputs
|
|
49
|
+
]
|
|
50
|
+
return analyze_many(
|
|
51
|
+
client,
|
|
52
|
+
selected_jobs,
|
|
53
|
+
concurrency=request.concurrency,
|
|
54
|
+
on_result=on_result,
|
|
55
|
+
run=run,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _field_as_dict(value: Any) -> dict[str, Any] | None:
|
|
60
|
+
if isinstance(value, dict):
|
|
61
|
+
return value
|
|
62
|
+
if hasattr(value, "as_dict"):
|
|
63
|
+
mapped = value.as_dict()
|
|
64
|
+
return mapped if isinstance(mapped, dict) else None
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _coerce_field_value(field: Any) -> Any:
|
|
69
|
+
if field is None or isinstance(field, (str, int, float, bool)):
|
|
70
|
+
return field
|
|
71
|
+
mapped = _field_as_dict(field)
|
|
72
|
+
if mapped is None:
|
|
73
|
+
return str(field)
|
|
74
|
+
for key in (
|
|
75
|
+
"valueString",
|
|
76
|
+
"valueNumber",
|
|
77
|
+
"valueInteger",
|
|
78
|
+
"valueBoolean",
|
|
79
|
+
"valueDate",
|
|
80
|
+
"valueTime",
|
|
81
|
+
"valueJson",
|
|
82
|
+
):
|
|
83
|
+
if mapped.get(key) is not None:
|
|
84
|
+
return mapped[key]
|
|
85
|
+
if mapped.get("valueArray") is not None:
|
|
86
|
+
return [_coerce_field_value(value) for value in mapped["valueArray"]]
|
|
87
|
+
if isinstance(mapped.get("valueObject"), dict):
|
|
88
|
+
return {
|
|
89
|
+
key: _coerce_field_value(value)
|
|
90
|
+
for key, value in mapped["valueObject"].items()
|
|
91
|
+
}
|
|
92
|
+
for key in ("value", "content"):
|
|
93
|
+
if mapped.get(key) is not None:
|
|
94
|
+
return mapped[key]
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _coerce_field_confidence(field: Any) -> float | None:
|
|
99
|
+
confidence = (
|
|
100
|
+
field.get("confidence")
|
|
101
|
+
if isinstance(field, dict)
|
|
102
|
+
else getattr(field, "confidence", None)
|
|
103
|
+
)
|
|
104
|
+
if confidence is None:
|
|
105
|
+
mapped = _field_as_dict(field)
|
|
106
|
+
confidence = mapped.get("confidence") if mapped else None
|
|
107
|
+
try:
|
|
108
|
+
return float(confidence) if confidence is not None else None
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _child_field_entries(
|
|
114
|
+
field_name: str,
|
|
115
|
+
field: Any,
|
|
116
|
+
) -> dict[str, dict[str, Any]]:
|
|
117
|
+
mapped = _field_as_dict(field)
|
|
118
|
+
if mapped is None:
|
|
119
|
+
return {}
|
|
120
|
+
value_object = mapped.get("valueObject")
|
|
121
|
+
if isinstance(value_object, dict):
|
|
122
|
+
return {
|
|
123
|
+
f"{field_name}.{name}": {
|
|
124
|
+
"value": _coerce_field_value(value),
|
|
125
|
+
"confidence": _coerce_field_confidence(value),
|
|
126
|
+
}
|
|
127
|
+
for name, value in value_object.items()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
value_array = mapped.get("valueArray")
|
|
131
|
+
if not isinstance(value_array, list):
|
|
132
|
+
return {}
|
|
133
|
+
child_values: dict[str, list[Any]] = {}
|
|
134
|
+
child_confidences: dict[str, list[float]] = {}
|
|
135
|
+
for item in value_array:
|
|
136
|
+
item_mapping = _field_as_dict(item)
|
|
137
|
+
item_object = item_mapping.get("valueObject") if item_mapping else None
|
|
138
|
+
if not isinstance(item_object, dict):
|
|
139
|
+
continue
|
|
140
|
+
for name, value in item_object.items():
|
|
141
|
+
child_values.setdefault(name, []).append(_coerce_field_value(value))
|
|
142
|
+
confidence = _coerce_field_confidence(value)
|
|
143
|
+
if confidence is not None:
|
|
144
|
+
child_confidences.setdefault(name, []).append(confidence)
|
|
145
|
+
return {
|
|
146
|
+
f"{field_name}[].{name}": {
|
|
147
|
+
"value": values,
|
|
148
|
+
"confidence": (
|
|
149
|
+
round(sum(child_confidences[name]) / len(child_confidences[name]), 3)
|
|
150
|
+
if child_confidences.get(name)
|
|
151
|
+
else None
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
for name, values in child_values.items()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_fields_from_result(result: Any) -> dict[str, dict[str, Any]]:
|
|
159
|
+
"""Flatten result fields for analyzer-test coverage reporting."""
|
|
160
|
+
|
|
161
|
+
extracted: dict[str, dict[str, Any]] = {}
|
|
162
|
+
for content in getattr(result, "contents", None) or []:
|
|
163
|
+
fields = getattr(content, "fields", None)
|
|
164
|
+
if fields is None:
|
|
165
|
+
continue
|
|
166
|
+
if hasattr(fields, "items"):
|
|
167
|
+
items = fields.items()
|
|
168
|
+
elif hasattr(fields, "as_dict"):
|
|
169
|
+
items = fields.as_dict().items()
|
|
170
|
+
else:
|
|
171
|
+
continue
|
|
172
|
+
for name, value in items:
|
|
173
|
+
if name in extracted and extracted[name].get("value") not in (
|
|
174
|
+
None,
|
|
175
|
+
"",
|
|
176
|
+
[],
|
|
177
|
+
{},
|
|
178
|
+
):
|
|
179
|
+
continue
|
|
180
|
+
extracted[name] = {
|
|
181
|
+
"value": _coerce_field_value(value),
|
|
182
|
+
"confidence": _coerce_field_confidence(value),
|
|
183
|
+
}
|
|
184
|
+
extracted.update(_child_field_entries(name, value))
|
|
185
|
+
return extracted
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_populated_value(value: Any) -> bool:
|
|
189
|
+
if value is None or value == "":
|
|
190
|
+
return False
|
|
191
|
+
if isinstance(value, dict):
|
|
192
|
+
return any(_is_populated_value(item) for item in value.values())
|
|
193
|
+
if isinstance(value, list):
|
|
194
|
+
return any(_is_populated_value(item) for item in value)
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def analyzer_test_summary(samples: list[dict[str, Any]]) -> dict[str, Any]:
|
|
199
|
+
"""Build deterministic field-coverage and confidence statistics."""
|
|
200
|
+
|
|
201
|
+
low_threshold = 0.6
|
|
202
|
+
total = len(samples)
|
|
203
|
+
succeeded = sum(sample.get("status") == "ok" for sample in samples)
|
|
204
|
+
field_names = {
|
|
205
|
+
name
|
|
206
|
+
for sample in samples
|
|
207
|
+
for name in (sample.get("fields") or {})
|
|
208
|
+
}
|
|
209
|
+
per_field: dict[str, dict[str, Any]] = {}
|
|
210
|
+
for name in sorted(field_names):
|
|
211
|
+
populated = 0
|
|
212
|
+
low = 0
|
|
213
|
+
confidences: list[float] = []
|
|
214
|
+
for sample in samples:
|
|
215
|
+
field = (sample.get("fields") or {}).get(name)
|
|
216
|
+
if field is None or field.get("value") is None:
|
|
217
|
+
continue
|
|
218
|
+
if _is_populated_value(field["value"]):
|
|
219
|
+
populated += 1
|
|
220
|
+
confidence = field.get("confidence")
|
|
221
|
+
if isinstance(confidence, (int, float)):
|
|
222
|
+
confidences.append(float(confidence))
|
|
223
|
+
if confidence < low_threshold:
|
|
224
|
+
low += 1
|
|
225
|
+
per_field[name] = {
|
|
226
|
+
"populated": populated,
|
|
227
|
+
"populatedPct": round(populated / total * 100, 1) if total else 0.0,
|
|
228
|
+
"meanConfidence": (
|
|
229
|
+
round(sum(confidences) / len(confidences), 3)
|
|
230
|
+
if confidences
|
|
231
|
+
else None
|
|
232
|
+
),
|
|
233
|
+
"lowConfidenceCount": low,
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
"samplesTotal": total,
|
|
237
|
+
"samplesOk": succeeded,
|
|
238
|
+
"samplesFailed": total - succeeded,
|
|
239
|
+
"disclaimer": _TEST_DISCLAIMER,
|
|
240
|
+
"fields": per_field,
|
|
241
|
+
"lowConfidenceThreshold": low_threshold,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def execute_analyzer_test(
|
|
246
|
+
client: Any,
|
|
247
|
+
request: AnalyzerTestRequest,
|
|
248
|
+
*,
|
|
249
|
+
input_plan: InputPlan | None = None,
|
|
250
|
+
run: Callable[[Any, AnalyzeJob], Any] | None = None,
|
|
251
|
+
) -> dict[str, Any]:
|
|
252
|
+
"""Execute analyzer test samples and return a frontend-neutral report."""
|
|
253
|
+
|
|
254
|
+
planned = input_plan or _input_plan(request)
|
|
255
|
+
refs = [str(item.path) for item in planned.inputs]
|
|
256
|
+
jobs = [
|
|
257
|
+
AnalyzeJob(input_ref=ref, analyzer_id=request.name, out_path=None)
|
|
258
|
+
for ref in refs
|
|
259
|
+
]
|
|
260
|
+
samples: list[dict[str, Any]] = []
|
|
261
|
+
|
|
262
|
+
def collect(outcome: AnalyzeOutcome) -> None:
|
|
263
|
+
if outcome.ok:
|
|
264
|
+
samples.append(
|
|
265
|
+
{
|
|
266
|
+
"input": outcome.job.input_ref,
|
|
267
|
+
"status": "ok",
|
|
268
|
+
"fields": extract_fields_from_result(outcome.result),
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
samples.append(
|
|
273
|
+
{
|
|
274
|
+
"input": outcome.job.input_ref,
|
|
275
|
+
"status": "error",
|
|
276
|
+
"error": str(outcome.error)[:500],
|
|
277
|
+
"fields": {},
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
analyze_many(
|
|
282
|
+
client,
|
|
283
|
+
jobs,
|
|
284
|
+
concurrency=request.concurrency,
|
|
285
|
+
on_result=collect,
|
|
286
|
+
run=run,
|
|
287
|
+
)
|
|
288
|
+
order = {ref: index for index, ref in enumerate(refs)}
|
|
289
|
+
samples.sort(key=lambda sample: order.get(sample["input"], 0))
|
|
290
|
+
return {
|
|
291
|
+
"analyzerId": request.name,
|
|
292
|
+
"summary": analyzer_test_summary(samples),
|
|
293
|
+
"samples": samples,
|
|
294
|
+
}
|