File size: 5,530 Bytes
e201ae4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
"""spec_builder — derive a ToolSpec from a tool's pydantic InputModel (analytical tools round).

The shipped analytics tools are pure compute functions (`src/tools/analytics/*.py`)
registered as hand-written `ToolSpec` entries (`src/tools/registry.py`). As the new
analytical tools land, each one declares a pydantic **InputModel** whose fields ARE
its call arguments; this module turns that model — plus the three-part description
the Planner reads — into the exact `ToolSpec` shape the registry already uses, so the
model and the registry entry can never drift.

Why not a base class (decided with the agent team): the tools STAY pure functions —
the repo's deliberate "compute-only, easy to unit-test" design (see the STATUS note
in `analytics/temporal.py`). This helper is the entire "framework": one function to
build the registry spec from a model, one to compose the description string. A tool
inherits from nothing.

Contract kept byte-compatible with the hand-written specs (`src/tools/contracts.py`):

    input_schema = {"required": [...], "properties": {arg: {"type": ...}}}

The planner validator (Check 8a) reads `required` (presence-only) and treats
`properties` as prompt DOCUMENTATION, not a runtime type check (see the ToolSpec
docstring). pydantic's `model_json_schema()` emits a richer schema (nested `$defs`,
`anyOf` for optionals, per-field `title`); `input_schema_from_model` SIMPLIFIES it to
the shape above — one primary JSON type per field, plus any `Field(description=...)`
carried through so the per-input descriptions the tool team asked for reach the
planner prompt.
"""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel

from src.tools.contracts import ToolSpec

_NULL = "null"


def _primary_type(prop: dict[str, Any]) -> str | None:
    """The single JSON type to advertise for one field.

    Handles the three shapes pydantic emits: a direct ``{"type": ...}``; an
    optional/union ``anyOf``/``oneOf`` (take the first non-null member); and a
    ``$ref`` to a nested model or enum (no primitive type — return None so the
    caller omits ``type`` rather than inventing one).
    """
    t = prop.get("type")
    if isinstance(t, str):
        return t
    for union_key in ("anyOf", "oneOf"):
        for sub in prop.get(union_key, []):
            sub_t = sub.get("type")
            if isinstance(sub_t, str) and sub_t != _NULL:
                return sub_t
    return None


def _simplify(prop: dict[str, Any]) -> dict[str, Any]:
    """One pydantic property schema -> the lightweight registry property dict."""
    simplified: dict[str, Any] = {}
    primary = _primary_type(prop)
    if primary is not None:
        simplified["type"] = primary
    description = prop.get("description")
    if description:
        simplified["description"] = description
    return simplified


def input_schema_from_model(model: type[BaseModel]) -> dict[str, Any]:
    """Build the registry ``input_schema`` ({required, properties}) from a model.

    ``required`` mirrors the model's no-default fields; ``properties`` is one
    simplified entry per field (see `_simplify`). Property ORDER follows the
    model's field declaration order, so keep ``data`` (the Pattern A placeholder)
    first in every InputModel for readable planner prompts.
    """
    schema = model.model_json_schema()
    properties = schema.get("properties", {})
    return {
        "required": list(schema.get("required", [])),
        "properties": {name: _simplify(prop) for name, prop in properties.items()},
    }


def spec_from_model(
    *,
    name: str,
    category: str,
    output_kind: str,
    description: str,
    input_model: type[BaseModel],
    phase: str = "P0",
) -> ToolSpec:
    """A `ToolSpec` for a functional analytics tool, with `input_schema` from `input_model`.

    Args:
        name: the `analyze_*` tool name (matches the invoker dispatch key).
        category: dotted registry category, e.g. ``"analytics.timeseries"``.
        output_kind: the `ToolOutput.kind` the compute fn produces.
        description: the prompt-style string the Planner reads — build it with
            `build_description` so every tool follows the same when/when-not/output
            shape.
        input_model: the tool's pydantic InputModel; its fields are the tool args.
        phase: rollout phase (P0/P1/P2), default "P0" to match existing specs.
    """
    return ToolSpec(
        name=name,
        category=category,
        input_schema=input_schema_from_model(input_model),
        output_kind=output_kind,
        description=description,
        phase=phase,
    )


def build_description(
    *,
    summary: str,
    use_when: str,
    dont_use_when: list[str],
    output: str,
    examples: list[str] | None = None,
) -> str:
    """Compose a Planner-facing description in the house format (see visualization.py).

    Encodes the tool team's three required parts — WHEN to use, when NOT to use,
    and what the OUTPUT is — around a one-line summary, matching the existing
    `DESCRIPTION` constants so the planner prompt reads uniformly across old and
    new tools.
    """
    lines = [f"Summary: {summary}", "", f"USE WHEN {use_when}", "", "DON'T USE WHEN:"]
    lines += [f"  - {reason}" for reason in dont_use_when]
    lines += ["", f"OUTPUT: {output}"]
    if examples:  # None or [] -> omit the section
        lines += ["", "Example questions:"]
        lines += [f"  - {q}" for q in examples]
    return "\n".join(lines)