Files
lda-wf/src/wf_api/wrapper_hints.py
T

363 lines
12 KiB
Python

from __future__ import annotations
from copy import deepcopy
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field
JsonObject = dict[str, Any]
CONTROL_BOOLEAN_NAMES = {
"success",
"ok",
"failed",
"error",
"is_error",
"needs_input",
"requires_approval",
"approved",
"rejected",
"has_more",
"done",
"complete",
}
class WrapperHintConfidence(StrEnum):
"""Coarse confidence for generated wrapper scaffolding hints."""
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class WrapperOutcomePolicy(StrEnum):
"""How wrapper outcomes were chosen."""
PRESERVE_DECLARED = "preserve_declared"
MANUAL_MAPPING_REQUIRED = "manual_mapping_required"
class OutcomeCandidateKind(StrEnum):
"""Reason a field was offered as a possible outcome source."""
BOOLEAN_CONTROL_FIELD = "boolean_control_field"
class MissingDecisionKind(StrEnum):
"""Typed action item a human or LLM must decide before saving a wrapper."""
CHOOSE_OUTPUT_FIELDS = "choose_output_fields"
REVIEW_NESTED_OUTPUT = "review_nested_output"
CONFIRM_BOOLEAN_OUTCOMES = "confirm_boolean_outcomes"
CHOOSE_ERROR_MAPPING = "choose_error_mapping"
class OutcomeCandidate(BaseModel):
"""One possible outcome mapping that must not be applied automatically."""
kind: OutcomeCandidateKind
source: str = Field(description="Output path such as output.success.")
candidate_outcomes: list[str]
confidence: WrapperHintConfidence
reason: str
automatic: bool = False
class MissingDecision(BaseModel):
"""One explicit decision required before a wrapper should be saved."""
kind: MissingDecisionKind
message: str
class WrapperAuthoringHints(BaseModel):
"""Scaffold for creating a workflow wrapper around one capability."""
capability_name: str
confidence: WrapperHintConfidence
declared_outcomes: list[str]
suggested_wrapper_outcomes: list[str]
outcome_policy: WrapperOutcomePolicy
input_schema: JsonObject
state_schema: JsonObject
output_schema: JsonObject
input_map: dict[str, str]
output_map: dict[str, str]
outcome_candidates: list[OutcomeCandidate] = Field(default_factory=list)
missing_decisions: list[MissingDecision] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
def wrapper_hints_for_capability(
*,
capability_name: str,
input_schema: JsonObject,
output_schema: JsonObject,
outcomes: list[str] | tuple[str, ...],
) -> WrapperAuthoringHints:
"""Derive conservative wrapper scaffolding for one workflow capability.
The helper deliberately preserves declared outcomes and only proposes
boolean output fields as candidates. It must not infer business semantics or
create routes by itself.
"""
input_properties = _object_properties(input_schema)
required_input_fields = _required_property_names(input_schema)
hint_output_schema = workflow_output_schema_for_authoring(output_schema)
output_properties = _object_properties(hint_output_schema)
input_map = {
f"input.{name}": name
for name in sorted(input_properties)
if name in required_input_fields
}
output_map_properties = _default_output_map_properties(
output_schema, output_properties
)
output_map = {name: f"state.{name}" for name in sorted(output_map_properties)}
mapped_output_schema = _schema_with_local_definitions(
output_schema,
properties={
name: schema for name, schema in sorted(output_map_properties.items())
},
)
# The minimal wrapper stores mapped outputs in state and returns the same
# fields. Split this later only when wrappers support separate return shape.
state_schema = mapped_output_schema
wrapper_output_schema = mapped_output_schema
missing_decisions = _missing_decisions_for_output(hint_output_schema)
outcome_candidates = _boolean_outcome_candidates(output_properties)
if outcome_candidates:
missing_decisions.append(
MissingDecision(
kind=MissingDecisionKind.CONFIRM_BOOLEAN_OUTCOMES,
message=(
"Confirm whether boolean output fields should control "
"wrapper routing."
),
)
)
confidence = _confidence_for_hint(
input_schema=input_schema,
output_schema=hint_output_schema,
missing_decisions=missing_decisions,
outcome_candidates=outcome_candidates,
)
notes = [
"Hints are scaffolding, not semantic guarantees.",
(
"Declared outcomes are preserved; output-field outcome "
"inference is not automatic."
),
]
notes.extend(
f"Optional input {name!r} is not auto-bound; bind it explicitly if needed."
for name in sorted(input_properties)
if name not in required_input_fields
)
if _has_raw_mcp_content(output_schema):
notes.append(
"Raw MCP content blocks are not workflow-shaped. Use an explicit "
"wrapper or extraction node to handle TextContent, ResourceLink, "
"images, or mixed content before writing to typed state."
)
return WrapperAuthoringHints(
capability_name=capability_name,
confidence=confidence,
declared_outcomes=list(outcomes),
suggested_wrapper_outcomes=list(outcomes),
outcome_policy=WrapperOutcomePolicy.PRESERVE_DECLARED,
input_schema=input_schema,
state_schema=state_schema,
output_schema=wrapper_output_schema,
input_map=input_map,
output_map=output_map,
outcome_candidates=outcome_candidates,
missing_decisions=missing_decisions,
notes=notes,
)
def workflow_output_schema_for_authoring(output_schema: JsonObject) -> JsonObject:
"""Return the workflow-author-facing output schema for one capability.
Raw MCP ``content`` blocks stay raw. A wrapper may choose to extract
``content[0].text`` or handle resources/images, but the authoring surface
must not invent that decision as a top-level schema field.
"""
return _schema_with_local_definitions(
output_schema,
properties=_object_properties(output_schema),
)
def _object_properties(schema: JsonObject) -> dict[str, JsonObject]:
"""Return object properties that are themselves JSON Schema objects."""
properties = schema.get("properties")
if not isinstance(properties, dict):
return {}
return {
str(name): deepcopy(value)
for name, value in properties.items()
if isinstance(value, dict)
}
def _required_property_names(schema: JsonObject) -> set[str]:
"""Return well-formed top-level JSON Schema required property names."""
required = schema.get("required")
if not isinstance(required, list):
return set()
return {name for name in required if isinstance(name, str)}
def _schema_with_local_definitions(
schema: JsonObject,
*,
properties: dict[str, JsonObject],
) -> JsonObject:
"""Build an object schema while preserving local reusable definitions.
Capability schemas commonly use refs such as ``{"$ref":
"#/$defs/Snapshot"}``. Wrapper hints project selected top-level fields into
workflow state/output schemas, but those refs become invalid unless the
local definition block is copied with them.
"""
projected: JsonObject = {
"type": "object",
"properties": deepcopy(properties),
}
for definition_key in ("$defs", "definitions"):
definitions = schema.get(definition_key)
if isinstance(definitions, dict):
projected[definition_key] = deepcopy(definitions)
return projected
def _has_raw_mcp_content(schema: JsonObject) -> bool:
"""Return true when schema exposes MCP's raw content-block envelope."""
properties = _object_properties(schema)
content_schema = properties.get("content")
return isinstance(content_schema, dict) and content_schema.get("type") == "array"
def _default_output_map_properties(
raw_output_schema: JsonObject,
output_properties: dict[str, JsonObject],
) -> dict[str, JsonObject]:
"""Return fields safe enough to wire by default in generated hints.
Raw MCP ``content`` is a protocol envelope, not a workflow value. Keeping it
out of the default map prevents the scaffold from writing text/image/resource
blocks into typed state without an explicit extraction wrapper.
"""
if _has_raw_mcp_content(raw_output_schema):
return {
name: schema
for name, schema in output_properties.items()
if name != "content"
}
return output_properties
def _missing_decisions_for_output(output_schema: JsonObject) -> list[MissingDecision]:
"""Return explicit decisions required by output schema shape."""
properties = _object_properties(output_schema)
if not properties:
return [
MissingDecision(
kind=MissingDecisionKind.CHOOSE_OUTPUT_FIELDS,
message=(
"Capability output schema has no top-level object "
"properties to map."
),
)
]
decisions: list[MissingDecision] = []
for name, schema in sorted(properties.items()):
schema_type = schema.get("type")
if schema_type == "object" or schema_type == "array":
decisions.append(
MissingDecision(
kind=MissingDecisionKind.REVIEW_NESTED_OUTPUT,
message=(
f"Review output.{name}; nested or collection outputs "
"may need explicit mapping."
),
)
)
return decisions
def _boolean_outcome_candidates(
output_properties: dict[str, JsonObject],
) -> list[OutcomeCandidate]:
"""Return conservative candidate outcome mappings for control-like booleans."""
candidates: list[OutcomeCandidate] = []
for name, schema in sorted(output_properties.items()):
if schema.get("type") != "boolean":
continue
if name.casefold() not in CONTROL_BOOLEAN_NAMES:
continue
candidates.append(
OutcomeCandidate(
kind=OutcomeCandidateKind.BOOLEAN_CONTROL_FIELD,
source=f"output.{name}",
candidate_outcomes=_candidate_outcomes_for_boolean_name(name),
confidence=WrapperHintConfidence.MEDIUM,
reason="top-level boolean field with control-like name",
automatic=False,
)
)
return candidates
def _candidate_outcomes_for_boolean_name(name: str) -> list[str]:
"""Map known control-like boolean names to possible outcome labels."""
normalized = name.casefold()
if normalized in {"success", "ok", "done", "complete"}:
return ["success", "failure"]
if normalized in {"failed", "error", "is_error"}:
return ["error", "ok"]
if normalized in {"approved", "rejected"}:
return ["approved", "rejected"]
if normalized in {"needs_input", "requires_approval"}:
return [normalized, "done"]
if normalized == "has_more":
return ["has_more", "done"]
return ["true", "false"]
def _confidence_for_hint(
*,
input_schema: JsonObject,
output_schema: JsonObject,
missing_decisions: list[MissingDecision],
outcome_candidates: list[OutcomeCandidate],
) -> WrapperHintConfidence:
"""Assign coarse confidence from schema shape and pending decisions."""
if not _object_properties(input_schema) or not _object_properties(output_schema):
return WrapperHintConfidence.LOW
if any(
decision.kind == MissingDecisionKind.REVIEW_NESTED_OUTPUT
for decision in missing_decisions
):
return WrapperHintConfidence.LOW
if missing_decisions or outcome_candidates:
return WrapperHintConfidence.MEDIUM
return WrapperHintConfidence.HIGH
__all__ = [
"MissingDecision",
"MissingDecisionKind",
"OutcomeCandidate",
"OutcomeCandidateKind",
"WrapperAuthoringHints",
"WrapperHintConfidence",
"WrapperOutcomePolicy",
"workflow_output_schema_for_authoring",
"wrapper_hints_for_capability",
]