-
Notifications
You must be signed in to change notification settings - Fork 3.3k
[Content Understanding] Add Copilot skills for custom-analyzer authoring #47218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 17 commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
5ac4860
initial version
chienyuanchang 99eae81
clean and improve classifier
chienyuanchang 8fadeba
clean up
chienyuanchang 3639b5d
use .local_only as output folder
chienyuanchang c59c801
use our skills to set up env
chienyuanchang 5714cd6
improve by learning from our samples
chienyuanchang 9296d68
Merge branch 'main' into cu-sdk/custom-analyzer-sklls
chienyuanchang 75ae260
fix spell issue
chienyuanchang 4adf258
fix slash
chienyuanchang 7490180
Merge branch 'main' into cu-sdk/custom-analyzer-sklls
chienyuanchang e25cd0e
fix slash and spell
chienyuanchang c9b9dda
[temp fix] fix endpoint
chienyuanchang 15fe5d0
revert patch fix
chienyuanchang aaef159
fix it by rstrip
chienyuanchang 9485dae
Merge branch 'main' into cu-sdk/custom-analyzer-sklls
chienyuanchang 75fb66c
remove dup section
chienyuanchang 54b5a77
Merge branch 'main' into cu-sdk/custom-analyzer-sklls
chienyuanchang 3bb150f
update per comments
chienyuanchang 22ce302
Merge remote-tracking branch 'origin/main' into cu-sdk/custom-analyze…
chienyuanchang 0b23652
Bump version to 1.2.0b3 for unreleased skills entry
chienyuanchang 184f926
fix(docs): convert relative samples/ link to absolute URL
chienyuanchang 17d48b4
Add generated api.md and api.metadata.yml for 1.2.0b3
chienyuanchang c3a7946
add handoff step
chienyuanchang 9b7e2da
Merge branch 'main' into cu-sdk/custom-analyzer-sklls
chienyuanchang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
21 changes: 21 additions & 0 deletions
21
...entunderstanding/azure-ai-contentunderstanding/.github/skills/_shared/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # `_shared/` — library, not a skill | ||
|
|
||
| Pure-Python helpers imported by the authoring skill scripts under | ||
| `.github/skills/cu-sdk-generate-analyzer*/scripts/`. | ||
|
|
||
| The leading underscore marks this as a **library directory**, not a skill. It | ||
| is intentionally excluded from the Copilot skill picker. | ||
|
|
||
| Rules for code here: | ||
|
|
||
| - **No `azure.*` imports.** No network calls. No I/O beyond reading/parsing | ||
| caller-provided JSON. | ||
| - **No new runtime dependencies.** Standard library only. | ||
| - **Stable, small, well-tested.** Anything here is imported by multiple skill | ||
| scripts; breakage cascades. | ||
|
|
||
| Current modules: | ||
|
|
||
| - [`schema_validator.py`](schema_validator.py) — validates analyzer schema | ||
| JSON before any service call (catches `baseAnalyzerId` typos, missing | ||
| `fieldSchema`, missing `contentCategories` analyzer routes, etc.). |
265 changes: 265 additions & 0 deletions
265
...entunderstanding/azure-ai-contentunderstanding/.github/skills/_shared/schema_validator.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,265 @@ | ||
| """Pure-Python validator for Content Understanding analyzer schema JSON. | ||
|
|
||
| Catches structural mistakes (missing keys, unknown ``baseAnalyzerId`` values, | ||
| malformed ``contentCategories`` routes) **before** any call to the Content | ||
| Understanding service. Failing fast here gives users an actionable error | ||
| message and avoids a wasted service round-trip. | ||
|
|
||
| Design rules (see ``README.md`` in this directory): | ||
|
|
||
| * No ``azure.*`` imports. | ||
| * No network calls. | ||
| * Standard library only. | ||
|
|
||
| The validator accepts either a parsed ``dict`` (preferred) or a path to a | ||
| JSON file (convenience). | ||
|
|
||
| Public surface: | ||
|
|
||
| * :func:`validate_schema` — validate a parsed schema dict. | ||
| * :func:`validate_schema_file` — convenience wrapper that loads a JSON file. | ||
| * :data:`KNOWN_BASE_ANALYZER_IDS` — allow-list of ``baseAnalyzerId`` values. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
| from typing import Any, List, Mapping, Optional, Tuple, Union | ||
|
|
||
| #: Known ``baseAnalyzerId`` values shipped by the service. Sourced from the | ||
| #: Content Understanding documentation; update when the service adds new | ||
| #: prebuilt analyzers. | ||
| KNOWN_BASE_ANALYZER_IDS = frozenset( | ||
| { | ||
| "prebuilt-document", | ||
| "prebuilt-documentSearch", | ||
| "prebuilt-audio", | ||
| "prebuilt-audioSearch", | ||
| "prebuilt-video", | ||
| "prebuilt-videoSearch", | ||
|
chienyuanchang marked this conversation as resolved.
Outdated
|
||
| "prebuilt-invoice", | ||
| "prebuilt-receipt", | ||
| "prebuilt-imageAnalyzer", | ||
| "prebuilt-layout", | ||
| } | ||
| ) | ||
|
|
||
| _ALLOWED_FIELD_TYPES = frozenset( | ||
| {"string", "number", "integer", "boolean", "date", "time", "array", "object"} | ||
| ) | ||
|
|
||
| _ALLOWED_FIELD_METHODS = frozenset({"extract", "generate", "classify"}) | ||
|
|
||
|
|
||
| def validate_schema( | ||
| schema: Mapping[str, Any], | ||
| ) -> Tuple[bool, List[str]]: | ||
| """Validate a parsed analyzer schema. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| schema: | ||
| The parsed schema (a ``dict`` produced by ``json.load`` or equivalent). | ||
|
|
||
| Returns | ||
| ------- | ||
| tuple | ||
| ``(ok, errors)``. ``ok`` is ``True`` when the schema is structurally | ||
| valid. ``errors`` is the list of human-readable error messages | ||
| (empty when ``ok`` is ``True``). | ||
| """ | ||
|
|
||
| errors: List[str] = [] | ||
|
|
||
| if not isinstance(schema, Mapping): | ||
| return False, ["schema must be a JSON object at the top level"] | ||
|
|
||
| base = schema.get("baseAnalyzerId") | ||
| if base is None: | ||
| errors.append("missing required key: baseAnalyzerId") | ||
| elif not isinstance(base, str): | ||
| errors.append("baseAnalyzerId must be a string") | ||
| elif base not in KNOWN_BASE_ANALYZER_IDS: | ||
| errors.append( | ||
| "unknown baseAnalyzerId: " | ||
| f"{base!r}. Known values: {sorted(KNOWN_BASE_ANALYZER_IDS)}" | ||
| ) | ||
|
|
||
| config = schema.get("config") | ||
| if config is not None and not isinstance(config, Mapping): | ||
| errors.append("config, if present, must be an object") | ||
| # Bail out: without a well-typed config we can't tell whether this is | ||
| # a single-type or classify-and-route schema, and falling through | ||
| # would emit a confusing cascade of "missing fieldSchema" errors | ||
| # rooted in the same problem. | ||
| return False, errors | ||
|
|
||
| is_classify_route = ( | ||
| isinstance(config, Mapping) and "contentCategories" in config | ||
| ) | ||
|
|
||
| if is_classify_route: | ||
| errors.extend(_validate_classify_route(config)) | ||
| if "fieldSchema" in schema: | ||
| errors.append( | ||
| "classify-and-route schemas should not declare fieldSchema at " | ||
| "the top level; field extraction belongs in inner analyzers" | ||
| ) | ||
| else: | ||
| errors.extend(_validate_single_type(schema)) | ||
|
|
||
| return (not errors), errors | ||
|
|
||
|
|
||
| def validate_schema_file(path: Union[str, Path]) -> Tuple[bool, List[str]]: | ||
| """Validate a schema stored in a JSON file. | ||
|
|
||
| Loads the file, then delegates to :func:`validate_schema`. | ||
|
|
||
| Returns the same ``(ok, errors)`` tuple as :func:`validate_schema`. | ||
| """ | ||
|
|
||
| p = Path(path) | ||
| try: | ||
| with p.open("r", encoding="utf-8") as fp: | ||
| schema = json.load(fp) | ||
| except FileNotFoundError: | ||
| return False, [f"schema file not found: {p}"] | ||
| except json.JSONDecodeError as exc: | ||
| return False, [f"schema file is not valid JSON ({p}): {exc.msg} at line {exc.lineno}"] | ||
|
|
||
| return validate_schema(schema) | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Internal helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| def _validate_single_type(schema: Mapping[str, Any]) -> List[str]: | ||
| """Validate a single-doc-type extraction schema.""" | ||
|
|
||
| errors: List[str] = [] | ||
|
|
||
| field_schema = schema.get("fieldSchema") | ||
| if field_schema is None: | ||
| errors.append( | ||
| "missing required key: fieldSchema " | ||
| "(single-type schemas must declare fields to extract)" | ||
| ) | ||
| return errors | ||
|
|
||
| if not isinstance(field_schema, Mapping): | ||
| errors.append("fieldSchema must be an object") | ||
| return errors | ||
|
|
||
| fields = field_schema.get("fields") | ||
| if fields is None: | ||
| errors.append("fieldSchema.fields is required") | ||
| return errors | ||
|
|
||
| if not isinstance(fields, Mapping): | ||
| errors.append("fieldSchema.fields must be an object mapping field names to definitions") | ||
| return errors | ||
|
|
||
| if not fields: | ||
| errors.append("fieldSchema.fields must declare at least one field") | ||
|
|
||
| for name, definition in fields.items(): | ||
| errors.extend(_validate_field_definition(name, definition)) | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| def _validate_field_definition( | ||
| name: str, definition: Any, *, path: Optional[str] = None | ||
| ) -> List[str]: | ||
| errors: List[str] = [] | ||
| prefix = path or f"fieldSchema.fields[{name!r}]" | ||
|
|
||
| if not isinstance(definition, Mapping): | ||
| return [f"{prefix} must be an object"] | ||
|
|
||
| field_type = definition.get("type") | ||
| if field_type is None: | ||
| errors.append(f"{prefix}.type is required") | ||
| elif field_type not in _ALLOWED_FIELD_TYPES: | ||
| errors.append( | ||
| f"{prefix}.type {field_type!r} is not one of {sorted(_ALLOWED_FIELD_TYPES)}" | ||
| ) | ||
|
|
||
| method = definition.get("method") | ||
| if method is not None and method not in _ALLOWED_FIELD_METHODS: | ||
| errors.append( | ||
| f"{prefix}.method {method!r} is not one of {sorted(_ALLOWED_FIELD_METHODS)}" | ||
| ) | ||
|
|
||
| description = definition.get("description") | ||
| if description is not None and not isinstance(description, str): | ||
| errors.append(f"{prefix}.description must be a string") | ||
|
|
||
| # Recurse into nested object/array shapes so typos in child fields are | ||
| # caught here instead of at the service round-trip. | ||
| if field_type == "object": | ||
| props = definition.get("properties") | ||
| if props is not None: | ||
| if not isinstance(props, Mapping): | ||
| errors.append(f"{prefix}.properties must be an object") | ||
| else: | ||
| for child, child_def in props.items(): | ||
| errors.extend( | ||
| _validate_field_definition( | ||
| child, child_def, path=f"{prefix}.properties[{child!r}]" | ||
| ) | ||
| ) | ||
| elif field_type == "array": | ||
| items = definition.get("items") | ||
| if items is not None: | ||
| if not isinstance(items, Mapping): | ||
| errors.append(f"{prefix}.items must be an object") | ||
| else: | ||
| errors.extend( | ||
| _validate_field_definition( | ||
| "items", items, path=f"{prefix}.items" | ||
| ) | ||
| ) | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| def _validate_classify_route(config: Mapping[str, Any]) -> List[str]: | ||
| """Validate the classify-and-route portion of a schema.""" | ||
|
|
||
| errors: List[str] = [] | ||
|
|
||
| enable_segment = config.get("enableSegment") | ||
| if enable_segment is not True: | ||
| errors.append( | ||
| "classify-and-route schemas must set config.enableSegment = true" | ||
| ) | ||
|
|
||
| categories = config.get("contentCategories") | ||
| if not isinstance(categories, Mapping): | ||
| errors.append("config.contentCategories must be an object") | ||
| return errors | ||
|
|
||
| if not categories: | ||
| errors.append("config.contentCategories must declare at least one category") | ||
| return errors | ||
|
|
||
| for name, entry in categories.items(): | ||
| prefix = f"config.contentCategories[{name!r}]" | ||
| if not isinstance(entry, Mapping): | ||
| errors.append(f"{prefix} must be an object") | ||
| continue | ||
|
|
||
| description = entry.get("description") | ||
| if not isinstance(description, str) or not description.strip(): | ||
| errors.append(f"{prefix}.description is required and must be a non-empty string") | ||
|
|
||
| analyzer_id = entry.get("analyzerId") | ||
| if analyzer_id is not None and not isinstance(analyzer_id, str): | ||
| errors.append(f"{prefix}.analyzerId, if present, must be a string") | ||
|
|
||
| return errors | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.