File size: 20,118 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
"""
claude_api.py — Anthropic Claude as a caption-processing provider.

Uses Anthropic's tool-calling API with forced tool choice and CAPTION_JSON_SCHEMA
as the tool's input_schema. The model is constrained to emit JSON matching the
schema — equivalent in semantic guarantee to xgrammar-constrained Qwen output,
but produced by a far more capable model.

Primary use cases:
  1. Teacher labels for SFT — generate gold structured outputs from real
     captions (COCO, LAION, Flickr30k) to fine-tune Qwen3.5-0.8B on.
  2. Comparison baseline — see what near-perfect schema/faithfulness numbers
     look like on the same eval set Qwen runs against.

Requires:
  pip install anthropic
  export ANTHROPIC_API_KEY=sk-ant-...

Cost note: at ~250 input tokens + ~200 output tokens per caption, Claude Sonnet
costs roughly $0.003/sample (~$30 per 10K captions). Cheaper models (Haiku) are
available; pass `model=...` to swap.
"""

from __future__ import annotations
import json
import os
import time
import warnings
from pathlib import Path
from typing import Optional

from ..registry import SLOT_REGISTRY
from ..schema import CAPTION_JSON_SCHEMA
from . import ProviderResult

# TaskSpec import is lazy — inside the function — to avoid a hard dependency
# at module-load time for callers that don't use the task-driven path.


# ──────────────────────────────────────────────────────────────────────────────
# .env loading — minimal stdlib parser, no python-dotenv dependency.
#
# Cowork's VM doesn't inherit the host shell's environment, so a `.env` file
# inside the mounted repo folder is the most reliable way to get the API
# key in. Same pattern works for Claude Code and plain CLI use.
# ──────────────────────────────────────────────────────────────────────────────

def _load_dotenv(path: Path) -> int:
    """Parse a .env file and set unset vars in os.environ. Returns count set.

    Existing env vars are not overwritten (host env wins over .env file).
    Supports lines like:  KEY=value  /  KEY="quoted value"  /  # comments
    """
    if not path.is_file():
        return 0
    n_set = 0
    for raw in path.read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, val = line.partition("=")
        key = key.strip()
        # Strip surrounding quotes if present
        val = val.strip()
        if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
            val = val[1:-1]
        # Skip "export KEY=..." prefix if user copied a shell-style file
        if key.startswith("export "):
            key = key[len("export "):].strip()
        # Treat empty existing values as unset. Some sandboxes (e.g. Claude
        # Code / Cowork) inject blank ANTHROPIC_API_KEY into child processes
        # to mask the host's real key; .env must remain authoritative there.
        if key and not os.environ.get(key):
            os.environ[key] = val
            n_set += 1
    return n_set


def _autoload_dotenv() -> None:
    """Search common locations for a .env and load the first match.

    Priority: cwd/.env, then walk up to 3 parent dirs (for cases where the
    agent is invoked from a subdirectory of the repo).
    """
    cwd = Path.cwd().resolve()
    for candidate in [cwd, *list(cwd.parents)[:3]]:
        env_path = candidate / ".env"
        if env_path.is_file():
            _load_dotenv(env_path)
            return


# ──────────────────────────────────────────────────────────────────────────────
# System prompts — the strict/enhance distinction is encoded here.
#
# Both prompts pin the model to the registry-driven schema. The difference is
# what category fields each prompt licenses the model to populate:
#
#   strict     — descriptive only. style/mood → null. For SFT teacher labels
#                where we want grounded-only outputs to filter on.
#   enhance    — all categories. Style/mood may be inferred. For prompt-
#                enhancement training data.
# ──────────────────────────────────────────────────────────────────────────────

# Both prompts are intentionally sized so that (tool def + system) lands
# safely above Sonnet's 1024-token prompt-caching minimum. The original
# 165-token versions kept the cacheable prefix at ~1023 tokens — 1 below
# threshold — which silently disabled caching and cost ~60% more per call.
# The richer examples also tightened grounding (fewer style/mood leaks on
# strict, more consistent inference on enhance).

PROMPT_STRICT = """You are a caption-structuring assistant. Given an image caption,
emit JSON matching the provided schema. Your sole job is to extract structured
information explicitly stated in the input — never embellish, infer, or imagine
details that aren't there.

RULES:
- `subjects`: every entity named in the caption. Each subject has a `name`
  (a noun phrase from the caption) and optional `attributes` — adjectives or
  descriptors the caption explicitly attaches to that subject (color, age,
  expression, material, count, etc.).
- `actions`: verb phrases describing what's happening. Prefer the caption's
  own wording when possible.
- `setting`: use "indoor" or "outdoor" if the caption indicates it (kitchen,
  restaurant → indoor; park, beach → outdoor). Use "unknown" if no cue.
- `style`: ALWAYS null in strict mode. Do not infer style from content.
- `mood`: ALWAYS null in strict mode. Do not infer mood from content.
- Empty lists `[]` and `null` are correct outputs — DO NOT invent content
  just to populate a field.

EXAMPLES:
- "a young girl in a red dress" → subjects: [{name: "girl", attributes: ["young"]},
  {name: "dress", attributes: ["red"]}]; setting: "unknown"
- "a cat sleeping on a sofa" → subjects: [{name: "cat", attributes: []},
  {name: "sofa", attributes: []}], actions: ["sleeping on a sofa"];
  setting: "indoor"
- "the beach at sunset" → subjects: [{name: "beach", attributes: []}];
  setting: "outdoor"

WHAT TO AVOID:
- Adding subjects not named in the caption (no inferred "people" or
  "background figures" unless the caption mentions them).
- Inferring attributes the caption does not state (do not add "fluffy" for
  a cat just because cats are typically fluffy).
- Setting `style` or `mood` to anything other than null.

Call the `emit_caption_schema` tool with your structured output.""".strip()


PROMPT_ENHANCE = """You are a caption-structuring assistant. Given an image caption,
emit JSON matching the provided schema. Extract grounded content faithfully, and
where the schema licenses inference, draw it from the caption's content rather
than imagining unrelated detail.

RULES:
- `subjects`, `actions`, subject `attributes`: ONLY content explicitly named
  in the caption. Use noun phrases from the caption itself; do not invent
  entities or descriptors.
- `setting`: "indoor" or "outdoor" if the caption indicates or strongly
  implies it (kitchen, restaurant → indoor; park, beach → outdoor);
  otherwise "unknown".
- `style`: you MAY infer a visual style (e.g. "photorealistic",
  "watercolor", "cyberpunk illustration", "vintage film", "anime") when
  the caption suggests one. Leave null if there's no signal.
- `mood`: you MAY infer a mood from the caption's content (e.g. "tense",
  "celebratory", "melancholy", "playful", "serene"). Leave null if neutral.

EXAMPLES:
- "an oil painting of a stormy sea" → subjects: [{name: "sea",
  attributes: ["stormy"]}]; setting: "outdoor"; style: "oil painting";
  mood: "tense"
- "a child laughing at a birthday party" → subjects: [{name: "child",
  attributes: []}], actions: ["laughing"]; setting: "indoor";
  style: null; mood: "celebratory"
- "a sketch of a hand holding a pencil" → subjects: [{name: "hand",
  attributes: []}, {name: "pencil", attributes: []}],
  actions: ["holding a pencil"]; setting: "unknown"; style: "sketch";
  mood: null

Call the `emit_caption_schema` tool with your structured output.""".strip()


# ──────────────────────────────────────────────────────────────────────────────
# Pricing table (per million tokens). Update when Anthropic publishes new rates.
# Used only to estimate cost_usd in ProviderResult — not authoritative.
# ──────────────────────────────────────────────────────────────────────────────

_PRICING = {
    # model_id_substring → (input_$/Mtok, output_$/Mtok)
    "claude-opus-4":    (15.0, 75.0),
    "claude-sonnet-4":  ( 3.0, 15.0),
    "claude-haiku-4":   ( 0.80,  4.0),
    # legacy 3.x models, in case someone pins to them
    "claude-3-5-sonnet":(3.0, 15.0),
    "claude-3-5-haiku": (0.80, 4.0),
}


def _estimate_cost(
    model_id: str,
    n_in: int,
    n_out: int,
    n_cache_create: int = 0,
    n_cache_read: int = 0,
) -> float:
    """Cost in USD for a single call. Cache write at 1.25x input, read at 0.10x."""
    rates = next((v for k, v in _PRICING.items() if k in model_id), None)
    if rates is None:
        return 0.0
    in_rate, out_rate = rates
    return (
        (n_in / 1_000_000) * in_rate
        + (n_cache_create / 1_000_000) * (in_rate * 1.25)
        + (n_cache_read / 1_000_000) * (in_rate * 0.10)
        + (n_out / 1_000_000) * out_rate
    )


# ──────────────────────────────────────────────────────────────────────────────
# Schema slimming — Pydantic's model_json_schema() is verbose by default
# (per-field "title", "default", "$defs/$ref" for nested types). Anthropic's
# tool-use enforcer ignores those cosmetic fields, but they cost input tokens
# on every uncached call. Stripping them cuts the tool definition roughly in
# half while keeping the constraints (types, enums, maxLength, maxItems, etc).
# ──────────────────────────────────────────────────────────────────────────────

_STRIP_KEYS = {"title", "default", "description"}


def _slim_schema(schema: dict) -> dict:
    """Return a copy of `schema` with cosmetic keys removed and $defs inlined.

    Drops: title, default, description (the model's tool-use enforcement
    doesn't read them). Inlines local $defs/$ref pairs so nested types like
    SubjectValue appear directly under their parent property. Constraints
    (types, enums, anyOf, maxLength, minLength, maxItems, required) are kept.
    """
    import copy
    schema = copy.deepcopy(schema)
    defs = schema.pop("$defs", {})

    def resolve(node):
        if isinstance(node, dict):
            if "$ref" in node and node["$ref"].startswith("#/$defs/"):
                name = node["$ref"][len("#/$defs/"):]
                return resolve(defs.get(name, {}))
            return {k: resolve(v) for k, v in node.items() if k not in _STRIP_KEYS}
        if isinstance(node, list):
            return [resolve(x) for x in node]
        return node

    return resolve(schema)


# ──────────────────────────────────────────────────────────────────────────────
# Provider
# ──────────────────────────────────────────────────────────────────────────────

class ClaudeProvider:
    """Caption-processing provider backed by Anthropic's Claude API.

    Loads the anthropic SDK lazily so importing the package doesn't fail
    when anthropic isn't installed and the user just wants the Qwen path.
    """

    def __init__(
        self,
        model: str = "claude-sonnet-4-6",
        api_key: Optional[str] = None,
        max_retries: int = 3,
        retry_backoff: float = 2.0,
    ):
        try:
            import anthropic
        except ImportError as e:
            raise ImportError(
                "ClaudeProvider requires the `anthropic` package. "
                "Install with: pip install anthropic"
            ) from e

        # Cowork/Claude Code don't inherit the host shell environment — load
        # a project-local .env if one exists. Has no effect when the key is
        # already in os.environ.
        _autoload_dotenv()

        api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
        if not api_key:
            raise RuntimeError(
                "No Anthropic API key. Set ANTHROPIC_API_KEY in your shell, "
                "drop a `.env` file with ANTHROPIC_API_KEY=... in the repo "
                "root, or pass api_key= to ClaudeProvider(...)."
            )

        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model
        self.max_retries = max_retries
        self.retry_backoff = retry_backoff

        # The tool definition is the JSON Schema generated by the registry.
        # Forcing tool use guarantees the output is schema-valid.
        #
        # NOTE: we keep the *verbose* pydantic schema rather than passing
        # _slim_schema(CAPTION_JSON_SCHEMA) here. The slim form saves ~110
        # tokens per call, but it also pushes the (tools + system) cacheable
        # prefix below Sonnet's 1024-token minimum, which silently disables
        # prompt caching — losing ~60% in subsequent-call savings. The
        # _slim_schema helper stays available for cases where caching is
        # off (e.g. one-shot calls) or for models with a smaller minimum.
        self._tool = {
            "name": "emit_caption_schema",
            "description": (
                "Emit the structured caption representation. The input_schema "
                "follows the qwen-test-runner slot registry."
            ),
            "input_schema": CAPTION_JSON_SCHEMA,
        }

    def process(
        self,
        caption: str,
        prompt: str = "strict",
        max_tokens: int = 1024,
        task=None,  # Optional[TaskSpec] — takes precedence over prompt= when set
    ) -> ProviderResult:
        """Convert one caption to schema-conformant JSON.

        Two modes (use one):

          task=<TaskSpec>     — task-driven: system prompt + tool schema come
                                from the TaskSpec. mode_tag = "claude_<task.name>".
                                Preferred for the per-task SFT pipeline.

          prompt="strict"|"enhance"|"<custom>" — legacy path. Uses the module-level
                                PROMPT_STRICT/PROMPT_ENHANCE constants and the
                                universal CAPTION_JSON_SCHEMA as the tool's
                                input_schema. Kept for back-compat with data_gen.py.
        """
        if task is not None:
            # Task-driven path. We build a tool definition locally rather than
            # reuse self._tool so the per-task schema overlay takes effect.
            tool = {
                "name": "emit_caption_schema",
                "description": self._tool["description"],
                "input_schema": task.tool_schema,
            }
            sys_prompt = task.system_prompt
            mode_tag = f"claude_{task.name}"
        else:
            tool = self._tool
            if prompt == "strict":
                sys_prompt = PROMPT_STRICT
                mode_tag = "claude_strict"
            elif prompt == "enhance":
                sys_prompt = PROMPT_ENHANCE
                mode_tag = "claude_enhance"
            else:
                sys_prompt = prompt
                mode_tag = "claude_custom"

        response = self._call_with_retry(
            system=sys_prompt,
            user=caption,
            max_tokens=max_tokens,
            tool=tool,
        )

        # Find the tool_use block. Forced tool_choice means there's always
        # exactly one — but we extract by type, not position, for safety.
        tool_input = None
        for block in response.content:
            if block.type == "tool_use" and block.name == "emit_caption_schema":
                tool_input = block.input
                break
        if tool_input is None:
            raise RuntimeError(
                f"Claude returned no tool_use block. Stop reason: {response.stop_reason!r}"
            )

        raw_json = json.dumps(tool_input, separators=(",", ":"))

        usage = response.usage
        n_in = usage.input_tokens
        n_out = usage.output_tokens
        n_cache_create = getattr(usage, "cache_creation_input_tokens", 0) or 0
        n_cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
        cost = _estimate_cost(self.model, n_in, n_out, n_cache_create, n_cache_read)

        return ProviderResult(
            mode=mode_tag,
            raw_text=raw_json,
            backend="claude",
            n_input_tokens=n_in,
            n_output_tokens=n_out,
            cost_usd=cost,
            n_cache_creation_tokens=n_cache_create,
            n_cache_read_tokens=n_cache_read,
        )

    def _call_with_retry(self, system: str, user: str, max_tokens: int, tool=None):
        """Anthropic call with exponential backoff on rate-limit / transient errors.

        tool: optional override for the tool definition. Defaults to self._tool
              (the universal schema). Task-driven callers pass their own.
        """
        import anthropic  # already imported in __init__, just re-bind name

        tool = tool if tool is not None else self._tool
        last_err: Optional[Exception] = None
        for attempt in range(self.max_retries):
            try:
                # `cache_control` on the last system block marks the cache
                # breakpoint. Everything before/including it (tools + system)
                # is cached; the user message remains variable per-call.
                # Sonnet's minimum cacheable prefix is 1024 tokens — our
                # tool def + system prompt sits just above that.
                return self.client.messages.create(
                    model=self.model,
                    max_tokens=max_tokens,
                    system=[{
                        "type": "text",
                        "text": system,
                        "cache_control": {"type": "ephemeral"},
                    }],
                    tools=[tool],
                    tool_choice={"type": "tool", "name": "emit_caption_schema"},
                    messages=[{"role": "user", "content": f"Caption: {user}"}],
                )
            except (anthropic.RateLimitError, anthropic.APIStatusError) as e:
                last_err = e
                sleep_s = self.retry_backoff ** attempt
                warnings.warn(
                    f"Claude API error (attempt {attempt + 1}/{self.max_retries}): "
                    f"{type(e).__name__}: {e}. Sleeping {sleep_s:.1f}s."
                )
                time.sleep(sleep_s)
        # All retries exhausted
        raise RuntimeError(f"Claude API failed after {self.max_retries} retries") from last_err