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/schema.py ADDED
@@ -0,0 +1,389 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT license.
3
+
4
+ """Custom-analyzer schema authoring (Click-free).
5
+
6
+ Pure helpers to generate a starter analyzer schema and to pick a template
7
+ completion model, plus a client-injected ``suggest_schema_from_sample`` that
8
+ derives a field schema from one local document via ``prebuilt-documentFieldSchema``.
9
+
10
+ Nothing here prints or resolves auth — callers pass a built client and the
11
+ resolved completion model; the command layer handles config, client
12
+ construction, and any user-facing warnings.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import mimetypes
18
+ from pathlib import Path
19
+ from typing import Any, Mapping, Protocol
20
+
21
+ from .defaults import COMPLETION_MODEL_PREFERENCE, PREFERRED_EMBEDDING_MODEL
22
+ from .errors import ValidationError
23
+ DOCUMENT_SAMPLE_EXTS = frozenset(
24
+ {
25
+ ".pdf",
26
+ ".tiff",
27
+ ".docx",
28
+ ".xlsx",
29
+ ".pptx",
30
+ ".docm",
31
+ ".xlsm",
32
+ ".pptm",
33
+ ".doc",
34
+ ".xls",
35
+ ".ppt",
36
+ ".odt",
37
+ ".ods",
38
+ ".odp",
39
+ ".epub",
40
+ ".txt",
41
+ ".html",
42
+ ".md",
43
+ ".rtf",
44
+ ".xml",
45
+ ".json",
46
+ ".csv",
47
+ ".tsv",
48
+ ".kml",
49
+ ".eml",
50
+ ".msg",
51
+ }
52
+ )
53
+
54
+ MODALITY_BASE: dict[str, str] = {
55
+ "document": "prebuilt-document",
56
+ "image": "prebuilt-image",
57
+ "audio": "prebuilt-audio",
58
+ "video": "prebuilt-video",
59
+ }
60
+
61
+ FIELD_SCHEMA_SUGGEST_ANALYZER_ID = "prebuilt-documentFieldSchema"
62
+
63
+
64
+ class _ModelDeploymentConfig(Protocol):
65
+ @property
66
+ def model_deployments(self) -> Mapping[str, str]: ...
67
+
68
+
69
+ def starter_schema(
70
+ analyzer_id: str,
71
+ base: str,
72
+ modality: str,
73
+ api_version: str,
74
+ *,
75
+ completion_model: str,
76
+ template_type: str,
77
+ ) -> dict[str, Any]:
78
+ """A minimal, valid analyzer schema for an agent/human to fill in.
79
+
80
+ The resolved ``apiVersion`` is stamped so authored schemas are
81
+ self-describing.
82
+ """
83
+ extraction_fields = {
84
+ "example_string_field": {
85
+ "type": "string",
86
+ "method": "extract",
87
+ "description": (
88
+ "TODO: replace this field. Describe specifically what to extract, "
89
+ "where it appears in the document, and any formatting expectations. "
90
+ "Example: 'Full legal name of the vendor as printed in the invoice header.'"
91
+ ),
92
+ "estimateSourceAndConfidence": True,
93
+ },
94
+ "example_number_field": {
95
+ "type": "number",
96
+ "method": "extract",
97
+ "description": (
98
+ "TODO: replace this field. Use for amounts, totals, rates, or "
99
+ "other numeric values. Example: 'Invoice total amount before tax.'"
100
+ ),
101
+ "estimateSourceAndConfidence": True,
102
+ },
103
+ "example_summary": {
104
+ "type": "string",
105
+ "method": "generate",
106
+ "description": (
107
+ "Provide a one-line summary of the document's main purpose and "
108
+ "key outcome. Keep it concise and factual."
109
+ ),
110
+ },
111
+ "example_classify_field": {
112
+ "type": "string",
113
+ "method": "classify",
114
+ "description": (
115
+ "TODO: replace this field. Use `classify` only when the value comes "
116
+ "from a closed set. Always include `enum`."
117
+ ),
118
+ "enum": ["option_a", "option_b", "other"],
119
+ "enumDescriptions": {
120
+ "option_a": "Primary category when the content strongly matches pattern A.",
121
+ "option_b": "Secondary category when the content matches pattern B.",
122
+ "other": "Fallback category when neither option_a nor option_b applies.",
123
+ },
124
+ },
125
+ "example_table_field": {
126
+ "type": "array",
127
+ "method": "extract",
128
+ "description": (
129
+ "TODO: replace this field. Use for repeating table rows such as "
130
+ "line items, transactions, or schedule entries."
131
+ ),
132
+ "items": {
133
+ "type": "object",
134
+ "description": "One extracted table row.",
135
+ "properties": {
136
+ "column_description": {
137
+ "type": "string",
138
+ "method": "extract",
139
+ "description": "Text content for the description column.",
140
+ },
141
+ "column_amount": {
142
+ "type": "number",
143
+ "method": "extract",
144
+ "description": "Numeric value in the amount column.",
145
+ },
146
+ "column_category": {
147
+ "type": "string",
148
+ "method": "classify",
149
+ "description": (
150
+ "Classify the column value into a stable category used by "
151
+ "downstream business logic."
152
+ ),
153
+ "enum": ["product", "service", "fee", "tax", "other"],
154
+ "enumDescriptions": {
155
+ "product": "Physical good or inventory item.",
156
+ "service": "Labor or service charge.",
157
+ "fee": "Non-tax fee such as handling or processing.",
158
+ "tax": "Tax line item.",
159
+ "other": "Row does not fit the predefined categories.",
160
+ },
161
+ },
162
+ },
163
+ },
164
+ },
165
+ }
166
+
167
+ # Categories are description-only by default so the emitted template can be
168
+ # created immediately. Routing a category to another analyzer is optional and
169
+ # requires that analyzer (prebuilt or custom) to already exist — otherwise the
170
+ # service rejects create with InvalidAnalyzerId. See the description below.
171
+ classification_categories = {
172
+ "invoice": {
173
+ "description": (
174
+ "Vendor invoices requesting payment for goods or services, typically "
175
+ "including vendor details, line items, totals, and payment terms."
176
+ ),
177
+ },
178
+ "purchase_order": {
179
+ "description": (
180
+ "Purchase orders that authorize a purchase, typically including a PO "
181
+ "number, buyer/supplier details, ordered items, quantities, and prices."
182
+ ),
183
+ },
184
+ "receipt": {
185
+ "description": (
186
+ "Retail or expense receipts confirming a completed payment, typically "
187
+ "including merchant, date, purchased items, and total paid."
188
+ ),
189
+ },
190
+ "other": {
191
+ "description": (
192
+ "Fallback category for content that does not match any category above."
193
+ ),
194
+ },
195
+ }
196
+
197
+ if template_type == "classification":
198
+ return {
199
+ "apiVersion": api_version,
200
+ "analyzerId": analyzer_id,
201
+ "description": (
202
+ "TODO: classify inputs into the categories below; give each a clear "
203
+ "description. Optional: add \"analyzerId\": \"<existing-analyzer-id>\" to a "
204
+ "category to route matching content to that analyzer for extraction "
205
+ "(the analyzer — prebuilt like prebuilt-invoice or a custom one — must "
206
+ "already exist, otherwise create fails with InvalidAnalyzerId)."
207
+ ),
208
+ "baseAnalyzerId": base,
209
+ "config": {
210
+ "estimateFieldSourceAndConfidence": True,
211
+ "enableSegment": True,
212
+ "contentCategories": classification_categories,
213
+ },
214
+ "models": {"completion": completion_model, "embedding": PREFERRED_EMBEDDING_MODEL},
215
+ }
216
+
217
+ return {
218
+ "apiVersion": api_version,
219
+ "analyzerId": analyzer_id,
220
+ "description": f"TODO: one-sentence description of what this {modality} analyzer extracts.",
221
+ "baseAnalyzerId": base,
222
+ "fieldSchema": {
223
+ "name": f"{analyzer_id.replace('-', '_')}_schema",
224
+ "description": "TODO: one-sentence summary of the extraction.",
225
+ "fields": extraction_fields,
226
+ },
227
+ "config": {"estimateFieldSourceAndConfidence": True},
228
+ "models": {"completion": completion_model, "embedding": PREFERRED_EMBEDDING_MODEL},
229
+ }
230
+
231
+
232
+ def suggested_fields_from_result(result: Any) -> dict[str, Any]:
233
+ """Extract the ``schema.valueJson`` field map from a suggestion result."""
234
+ contents = getattr(result, "contents", None) or []
235
+ if not contents:
236
+ return {}
237
+ for content in contents:
238
+ fields = getattr(content, "fields", None)
239
+ if fields is None and isinstance(content, dict):
240
+ fields = content.get("fields")
241
+ if not isinstance(fields, dict):
242
+ if fields is not None and hasattr(fields, "as_dict"):
243
+ fields = fields.as_dict()
244
+ else:
245
+ continue
246
+ schema_field = fields.get("schema")
247
+ if schema_field is None:
248
+ continue
249
+ if hasattr(schema_field, "as_dict"):
250
+ schema_field = schema_field.as_dict()
251
+ if not isinstance(schema_field, dict):
252
+ continue
253
+
254
+ value_json = schema_field.get("valueJson")
255
+ if isinstance(value_json, dict) and value_json:
256
+ return value_json
257
+ return {}
258
+
259
+
260
+ def _add_placeholder_descriptions(fields: dict[str, Any]) -> None:
261
+ """Fill description gaps in service-suggested field definitions."""
262
+
263
+ def visit(definition: Any, placeholder: str) -> None:
264
+ if not isinstance(definition, dict):
265
+ return
266
+
267
+ description = definition.get("description")
268
+ if description is None or (isinstance(description, str) and not description.strip()):
269
+ definition["description"] = placeholder
270
+
271
+ properties = definition.get("properties")
272
+ if isinstance(properties, dict):
273
+ for property_name, property_definition in properties.items():
274
+ visit(
275
+ property_definition,
276
+ f"TODO: describe the '{property_name}' field.",
277
+ )
278
+
279
+ items = definition.get("items")
280
+ if isinstance(items, dict):
281
+ visit(
282
+ items,
283
+ "TODO: describe one item in this array.",
284
+ )
285
+
286
+ for field_name, field_definition in fields.items():
287
+ visit(
288
+ field_definition,
289
+ f"TODO: describe the '{field_name}' field.",
290
+ )
291
+
292
+
293
+ def template_completion_model(cfg: _ModelDeploymentConfig) -> str:
294
+ """Pick a completion model to stamp into a generated schema template."""
295
+ for model in COMPLETION_MODEL_PREFERENCE:
296
+ if model in cfg.model_deployments:
297
+ return model
298
+
299
+ for model in cfg.model_deployments:
300
+ if model.startswith("prebuilt-analyzer-"):
301
+ continue
302
+ if model == PREFERRED_EMBEDDING_MODEL or model.endswith("-mini"):
303
+ continue
304
+ return model
305
+
306
+ # Default completion model when none is configured (recommended: gpt-5.2).
307
+ return COMPLETION_MODEL_PREFERENCE[0]
308
+
309
+
310
+ def validate_document_sample(sample_path: Path) -> None:
311
+ """Raise a validation error if *sample_path* is unusable."""
312
+ if not sample_path.exists() or not sample_path.is_file():
313
+ raise ValidationError(f"sample file not found: {sample_path}")
314
+ if sample_path.suffix.lower() not in DOCUMENT_SAMPLE_EXTS:
315
+ raise ValidationError(
316
+ f"--from-sample expects a document file, got '{sample_path.suffix}'.",
317
+ hint="supported document formats include pdf/docx/pptx/xlsx/txt/html.",
318
+ )
319
+
320
+
321
+ def suggest_schema_from_sample(
322
+ client: Any,
323
+ *,
324
+ sample_path: Path,
325
+ analyzer_id: str,
326
+ api_version: str,
327
+ completion_model: str,
328
+ ) -> tuple[dict[str, Any], bool]:
329
+ """Suggest an extraction schema from one document sample via *client*.
330
+
331
+ Returns ``(payload, found_fields)`` where ``found_fields`` is ``True`` when
332
+ the service returned a non-empty field schema (otherwise ``payload`` is the
333
+ default extraction template). MVP behavior intentionally supports exactly
334
+ one local document sample.
335
+ """
336
+ validate_document_sample(sample_path)
337
+
338
+ payload = starter_schema(
339
+ analyzer_id,
340
+ base=MODALITY_BASE["document"],
341
+ modality="document",
342
+ api_version=api_version,
343
+ completion_model=completion_model,
344
+ template_type="extraction",
345
+ )
346
+
347
+ sample_bytes = sample_path.read_bytes()
348
+ sample_mime = mimetypes.guess_type(sample_path.name)[0] or "application/octet-stream"
349
+
350
+ input_payload: Any
351
+ try:
352
+ from azure.ai.contentunderstanding import models as _cu_models
353
+
354
+ input_cls = getattr(_cu_models, "AnalyzeInput", None) or getattr(
355
+ _cu_models, "AnalysisInput", None
356
+ )
357
+ if input_cls is None:
358
+ raise AttributeError("AnalyzeInput/AnalysisInput model is not available")
359
+
360
+ input_payload = input_cls(
361
+ name=sample_path.name,
362
+ mime_type=sample_mime,
363
+ data=sample_bytes,
364
+ )
365
+ except (ImportError, AttributeError, TypeError):
366
+ # SDK compatibility: older wheels may not expose AnalyzeInput.
367
+ input_payload = {
368
+ "name": sample_path.name,
369
+ "mime_type": sample_mime,
370
+ "data": sample_bytes,
371
+ }
372
+
373
+ poller = client.begin_analyze(
374
+ FIELD_SCHEMA_SUGGEST_ANALYZER_ID,
375
+ inputs=[input_payload],
376
+ )
377
+ result = poller.result()
378
+ suggested_fields = suggested_fields_from_result(result)
379
+ if suggested_fields:
380
+ _add_placeholder_descriptions(suggested_fields)
381
+ payload["fieldSchema"]["fields"] = suggested_fields
382
+ payload["description"] = (
383
+ f"Suggested from sample '{sample_path.name}' using prebuilt-documentFieldSchema."
384
+ )
385
+ payload["fieldSchema"]["description"] = (
386
+ "Suggested extraction schema derived from one sample document."
387
+ )
388
+ return payload, True
389
+ return payload, False