From b58bd862a4c8c32353cbb216a6f2f95ad40b262b Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 19 Apr 2026 17:10:56 -0600 Subject: [PATCH] think/detect_created: constrain generation with detect_created.schema.json Add think/detect_created.schema.json (Draft 2020-12) and pass it as json_schema= to the existing generate(...) call in detect_created(). This is the first non-talent-dispatcher consumer of the L1 json_schema kwarg threaded through in c030248d. Approach mirrors the L3 talent migrations (8c952dc4 sense, 50693752 story, 0e098e7b daily_schedule) but applied via direct generate() rather than the talent dispatcher: detect_created.md is loaded through think.prompts.load_prompt, not think.talent.get_talent, so the schema lives co-located at think/detect_created.schema.json and is passed explicitly. Schema uses the provider-intersection subset only (type, enum, pattern, required, additionalProperties, properties, minLength), with root additionalProperties: false and required: [day, time, confidence, source, utc]. The module memoizes the schema at import with a module-level _SCHEMA constant; a malformed schema fails import loudly. Caller wiring, parsing, UTC->local conversion, and the return shape are unchanged. think/models.py validates advisorily via Draft202012Validator and logs violations; no provider plumbing or caller edits were needed. Live provider validation deferred: the worktree has no .env and provider keys are unavailable. Advisory schema_validation will engage on the next real run against google (primary) and anthropic (backup), matching the 0e098e7b precedent. Tests: tests/test_detect_created_schema.py adds (1) Draft202012Validator schema-validity, (2) accept/reject matrix covering each field's constraint, and (3) a wiring assertion that detect_created() passes _SCHEMA to generate() via monkeypatched think.models.generate. Existing tests/test_importer.py mocks are unaffected (they return plain dicts and bypass the schema path). make ci green. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_detect_created_schema.py | 80 +++++++++++++++++++++++++++++ think/detect_created.py | 5 ++ think/detect_created.schema.json | 27 ++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/test_detect_created_schema.py create mode 100644 think/detect_created.schema.json diff --git a/tests/test_detect_created_schema.py b/tests/test_detect_created_schema.py new file mode 100644 index 000000000..f3df6b8ca --- /dev/null +++ b/tests/test_detect_created_schema.py @@ -0,0 +1,80 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import importlib +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +import think.models as models + +detect_created_mod = importlib.import_module("think.detect_created") + +DETECT_CREATED_SCHEMA_PATH = ( + Path(__file__).resolve().parents[1] / "think" / "detect_created.schema.json" +) + + +def _load_detect_created_schema() -> dict: + return json.loads(DETECT_CREATED_SCHEMA_PATH.read_text(encoding="utf-8")) + + +def test_detect_created_schema_file_is_valid_draft_2020_12(): + Draft202012Validator.check_schema(_load_detect_created_schema()) + + +def test_detect_created_schema_accepts_and_rejects_expected_values(): + schema = _load_detect_created_schema() + validator = Draft202012Validator(schema) + valid = { + "day": "20240315", + "time": "143052", + "confidence": "high", + "source": "QuickTime:CreateDate", + "utc": True, + } + + assert validator.is_valid(valid) + assert not validator.is_valid( + { + "day": "20240315", + "time": "143052", + "confidence": "high", + "source": "QuickTime:CreateDate", + } + ) + assert not validator.is_valid({**valid, "day": "2024-03-15"}) + assert not validator.is_valid({**valid, "time": "14:30:52"}) + assert not validator.is_valid({**valid, "confidence": "certain"}) + assert not validator.is_valid({**valid, "extra": "x"}) + assert not validator.is_valid({**valid, "source": ""}) + + +def test_detect_created_passes_schema_to_generate(monkeypatch): + captured = {} + + def fake_generate(**kwargs): + captured.update(kwargs) + return ( + '{"day": "20240315", "time": "143052", "confidence": "high", ' + '"source": "QuickTime:CreateDate", "utc": false}' + ) + + monkeypatch.setattr(models, "generate", fake_generate) + monkeypatch.setattr( + detect_created_mod, + "_extract_metadata", + lambda path: "QuickTime Create Date : 2024:03:15 14:30:52", + ) + + result = detect_created_mod.detect_created("/dev/null") + + assert captured["json_schema"] is detect_created_mod._SCHEMA + assert result == { + "day": "20240315", + "time": "143052", + "confidence": "high", + "source": "QuickTime:CreateDate", + "utc": False, + } diff --git a/think/detect_created.py b/think/detect_created.py index 273e39965..dc8ebe554 100644 --- a/think/detect_created.py +++ b/think/detect_created.py @@ -15,6 +15,10 @@ from typing import Optional from .prompts import load_prompt +_SCHEMA = json.loads( + (Path(__file__).parent / "detect_created.schema.json").read_text(encoding="utf-8") +) + def _load_system_prompt() -> str: """Load the system prompt from detect_created.txt file.""" @@ -99,6 +103,7 @@ def detect_created( thinking_budget=4096, system_instruction=_load_system_prompt(), json_output=True, + json_schema=_SCHEMA, ) try: diff --git a/think/detect_created.schema.json b/think/detect_created.schema.json new file mode 100644 index 000000000..42fa3259e --- /dev/null +++ b/think/detect_created.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["day", "time", "confidence", "source", "utc"], + "properties": { + "day": { + "type": "string", + "pattern": "^\\d{8}$" + }, + "time": { + "type": "string", + "pattern": "^\\d{6}$" + }, + "confidence": { + "type": "string", + "enum": ["high", "medium", "low"] + }, + "source": { + "type": "string", + "minLength": 1 + }, + "utc": { + "type": "boolean" + } + } +} -- 2.51.2