Результат агента может иметь верную структуру JSON и всё же неверно прочитать источник. Этот материал даёт Python-разработчику полный путь от одного разрешённого файла заметок до локального реестра: типизированный результат производителя, проверка источника и смысла, человеческое одобрение точных хешей и потребитель, записывающий и перечитывающий сгруппированный Markdown-отчёт. Используйте собственные заметки с явными обязательствами. При подготовке не использовались реальные заметки или результат модели.

Главное изменение

  • Что изменилось: OpenAI Agents SDK принимает Pydantic output_type, поэтому приложение может запросить определённый результат и проверить его до использования. Пример добавляет цитаты источника и версионируемый контракт результата.
  • Почему это важно: Разработчик, соединяющий агента со следующим шагом, может остановить неверно оформленный, неполный или необоснованный вывод до превращения в список действий. Человек всё ещё должен оценить, подтверждают ли цитаты каждую задачу, ответственного и дату и нет ли пропусков.
  • На что обратить внимание: Точка решения — граница потребителя. Рецепт создаёт только локальный реестр для разбора после одобрения точного запуска, источника и кандидата. Поздний импорт в систему задач потребует собственных полномочий, правил повторения и проверки чтением в месте назначения.

Подготовьте один разрешённый источник

Нужны Python 3.12, аккаунт OpenAI API с разрешением на эти заметки, проверяющий с доступом к оригиналу и модель, текущую поддержку структурированного вывода JSON-schema которой вы подтвердили в аккаунте. До запуска проверьте условия данных и актуальные цены. Не помещайте учётные данные в заметки или одобрение. Единственный вызов модели ниже — produce; остальная обработка локальная.

Сохраните оригинальные заметки в UTF-8 в notes.txt. Они должны содержать реальные явные обязательства из вашей работы. Назначьте стабильный source_id, например собственный идентификатор записи, и не меняйте ни байта до проверки и обработки. Создайте candidate/ до генерации; файл кандидата должен быть новым. Подготовьте отдельный выходной каталог для локального отчёта.

До запуска запишите хеш байтов источника:

Terminal
python -c "from pathlib import Path; import hashlib; print(hashlib.sha256(Path('notes.txt').read_bytes()).hexdigest())"

В изолированной среде Python 3.12 создайте recipe-requirements.txt с этими точными строками и установите пакеты:

Text
openai-agents==0.24.0
pydantic==2.14.1
Terminal
python -m pip install -r recipe-requirements.txt
mkdir candidate

Эти версии установились вместе в локальной среде Python 3.12.7 20 октября 2026 года. Проверьте совместимость и доступность модели при использовании. Сохраните полный скрипт ниже как action_register_recipe.py в том же каталоге. У него две команды: produce создаёт кандидата и квитанцию запуска; consume формирует отчёт лишь после валидации и одобрения.

Запустите типизированного производителя

Контракт Extraction требует версию action-register/v1, статус ready, partial или blocked, ID источника и SHA-256 точных байтов файла, действия и gap_note. Для каждого действия нужны текст, точная цитата и диапазон строк источника с отсчётом от 1, ответственный и дата, допускающие null, а также complete или needs_review как статус доказательства. Null у ответственного или даты означает отсутствие этих сведений в заметках. Модели запрещено их выводить самостоятельно. Все поля обязательны, дополнительные отвергаются.

Явно выберите модель структурированного вывода. Укажите собственные разрешённые путь и идентификатор:

Terminal
python action_register_recipe.py produce --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --model YOUR-STRUCTURED-OUTPUT-MODEL

Прочтите candidate/actions.json рядом с candidate/actions.run.json. Второй файл фиксирует UUID запуска, хеши источника и кандидата, завершившего агента и запрошенную модель. При сбое вместо этого создаётся диагностическая запись .failure.json с ID запуска в суффиксе. Прерывание, None или итоговая простая строка, либо неожиданный завершающий агент никогда не считаются успехом. Команда не сохраняет возобновляемое состояние SDK RunState; квитанция ошибки не позволяет продолжить запуск. Изучите запрос, кандидата и последующее состояние, прежде чем решать о новом запуске.

Проверьте смысл и одобрите точного кандидата

Приложение проверяет строгие типы и обязательные поля, идентичность источника, готовый статус, хотя бы одно полное действие, допустимые диапазоны строк, наличие цитаты в диапазоне, непустые задачу или ответственного и канонические даты YYYY-MM-DD. Это доказывает структуру и расположение цитаты, но не то, что фраза является обязательством, имя или дата имеют заявленный смысл либо все обязательства найдены.

Прочитайте исходные заметки и каждое предложенное действие. Сравните смысл, ответственного, дату, цитату и пропуски. Устраняйте неоднозначность новым проверенным запуском; необоснованные, частичные и заблокированные кандидаты не одобряйте. Убедившись, что неизменённый файл и кандидат соответствуют actions.run.json, запишите approval.json ровно с этими семью ключами. Скопируйте ID и хеши из проверенной квитанции. Укажите реальный идентификатор проверяющего и содержательное описание фактически сделанных проверок:

JSON
{
  "approved": true,
  "run_id": "COPY-RUN-ID",
  "source_id": "OWNED-ID",
  "source_sha256": "COPY-SOURCE-SHA256",
  "payload_sha256": "COPY-PAYLOAD-SHA256",
  "reviewer": "YOUR-REVIEWER-ID",
  "review_note": "Describe the source, task, owner, date, quotation and omission checks actually performed."
}

approved должен быть логическим значением JSON true, а не строкой или числом. Остальные значения — строки; проверяющий и примечание не могут быть пустыми. Дополнительные поля приводят к отказу. Одобрение связывает одного кандидата с ревизией источника, а не с будущей переработкой.

Обработайте и перечитайте отчёт

Terminal
python action_register_recipe.py consume --source-id OWNED-ID --source notes.txt --candidate candidate/actions.json --approval approval.json --out private-output

Потребитель группирует принятые действия в Assigned with dates, Needs an owner и Needs a date. Каждая запись содержит задачу, состояние ответственного и даты, цитату и строки источника. Он пишет action-register-<payload-hash-prefix>.md и соседний файл .receipt.json с хешами запуска, источника, кандидата и отчёта. Откройте оба, сопоставьте процитированные действия с заметками и проверьте report_sha256 по байтам Markdown-файла. Верная квитанция означает только создание и обратное чтение локального файла; она не одобряет запись во внешнюю систему задач.

Команда обработки выводит реальные пути отчёта и квитанции. Используйте их для проверки байтов:

Terminal
python -c "from pathlib import Path; import hashlib,json,sys; report=Path(sys.argv[1]); receipt=json.loads(Path(sys.argv[2]).read_text(encoding='utf-8')); assert hashlib.sha256(report.read_bytes()).hexdigest()==receipt['report_sha256']; print('report hash matches receipt')" PATH-PRINTED-FOR-REPORT PATH-PRINTED-FOR-RECEIPT

Получив кандидата, проверьте отказ явно синтетическим структурным образцом. Скопируйте JSON в отдельный файл, удалите любое обязательное поле первого действия и вызовите consume с неверным файлом и новым пустым выходным каталогом. Сбой должен произойти до создания результата. Не считайте образец фактическим извлечением и не одобряйте. Например, следующая локальная проверка удаляет evidence_quote из копии:

Terminal
python -c "import json; from pathlib import Path; p=json.loads(Path('candidate/actions.json').read_text(encoding='utf-8')); p['actions'][0].pop('evidence_quote'); Path('candidate/SYNTHETIC-malformed.json').write_text(json.dumps(p), encoding='utf-8')"
python action_register_recipe.py consume --source-id OWNED-ID --source notes.txt --candidate candidate/SYNTHETIC-malformed.json --approval approval.json --out synthetic-rejection-output

Вторая команда должна завершиться ошибкой, а выходной каталог остаться отсутствующим. Не используйте образец без первого действия в реальном кандидате: он уже неполон для потребителя. При неопределённой записи сначала проверьте детерминированный отчёт и квитанцию. Существующие идентичные байты можно использовать повторно; другие останавливают процесс для сверки. Изменение источника или версии схемы требует новой проверки. Не повторяйте вызов модели лишь из-за неясного ответа локального потребителя.

Полный рецепт Python

Скопируйте этот скрипт без изменений в action_register_recipe.py:

Python
"""Private, bounded recipe. No model call occurs unless `produce` is explicitly run."""

import argparse
import hashlib
import json
import uuid
from datetime import date
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr, ValidationError


class StrictModel(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)


class Action(StrictModel):
    action_text: StrictStr
    evidence_quote: StrictStr
    line_start: StrictInt
    line_end: StrictInt
    owner: StrictStr | None
    due_date: StrictStr | None
    evidence_status: Literal["complete", "needs_review"]


class Extraction(StrictModel):
    schema_version: Literal["action-register/v1"]
    status: Literal["ready", "partial", "blocked"]
    source_id: StrictStr
    source_sha256: StrictStr
    actions: list[Action]
    gap_note: StrictStr | None


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def canonical(payload: Extraction) -> bytes:
    return json.dumps(payload.model_dump(mode="json"), sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode("utf-8")


def check(payload: Extraction, source_id: str, source: bytes) -> None:
    if payload.source_id != source_id or payload.source_sha256 != digest(source):
        raise ValueError("source identity or revision mismatch")
    lines = source.decode("utf-8").splitlines()
    if payload.status == "ready" and (not payload.actions or payload.gap_note is not None):
        raise ValueError("ready requires actions and no gap")
    if payload.status != "ready":
        raise ValueError("partial or blocked result cannot reach consumer")
    for index, item in enumerate(payload.actions, 1):
        if item.evidence_status != "complete":
            raise ValueError(f"action {index}: evidence needs review")
        if not item.action_text.strip() or not item.evidence_quote.strip():
            raise ValueError(f"action {index}: empty task or evidence")
        if not 1 <= item.line_start <= item.line_end <= len(lines):
            raise ValueError(f"action {index}: invalid source line range")
        segment = "\n".join(lines[item.line_start - 1:item.line_end])
        if item.evidence_quote not in segment:
            raise ValueError(f"action {index}: quotation absent from source span")
        if item.owner is not None and not item.owner.strip():
            raise ValueError(f"action {index}: blank owner")
        if item.due_date is not None:
            try:
                if date.fromisoformat(item.due_date).isoformat() != item.due_date:
                    raise ValueError()
            except ValueError as exc:
                raise ValueError(f"action {index}: due date must be YYYY-MM-DD") from exc


def report(payload: Extraction) -> str:
    """Application consumer: a triage register grouped by missing ownership/date."""
    groups = [
        ("Assigned with dates", lambda x: x.owner is not None and x.due_date is not None),
        ("Needs an owner", lambda x: x.owner is None),
        ("Needs a date", lambda x: x.owner is not None and x.due_date is None),
    ]
    rows = ["# Action register for " + payload.source_id, "", "Review artifact; source meaning was approved separately.", ""]
    for heading, predicate in groups:
        rows += ["## " + heading, ""]
        matches = [(n, a) for n, a in enumerate(payload.actions, 1) if predicate(a)]
        for number, action in matches:
            rows += [f"{number}. {action.action_text}", f"   Owner: {action.owner or 'Unspecified'}; due: {action.due_date or 'Unspecified'}", f"   Source lines {action.line_start}-{action.line_end}: {action.evidence_quote}", ""]
        if not matches:
            rows += ["None.", ""]
    return "\n".join(rows)


def load_payload(path: Path) -> Extraction:
    return Extraction.model_validate_json(path.read_bytes(), strict=True)


def consume(source_id: str, source_path: Path, payload_path: Path, approval_path: Path, out_dir: Path) -> tuple[Path, Path]:
    source = source_path.read_bytes()
    payload = load_payload(payload_path)
    check(payload, source_id, source)
    run = json.loads(payload_path.with_suffix(".run.json").read_text(encoding="utf-8"))
    payload_hash = digest(canonical(payload))
    if run.get("source_id") != source_id or run.get("source_sha256") != digest(source) or run.get("payload_sha256") != payload_hash or run.get("last_agent") != "Owned-source action extractor" or not str(run.get("run_id", "")).strip():
        raise ValueError("producer receipt does not match accepted candidate")
    approval = json.loads(approval_path.read_text(encoding="utf-8"))
    if (type(approval) is not dict
            or set(approval) != {"approved", "run_id", "source_id", "source_sha256", "payload_sha256", "reviewer", "review_note"}
            or approval["approved"] is not True
            or any(type(approval[key]) is not str for key in ("run_id", "source_id", "source_sha256", "payload_sha256", "reviewer", "review_note"))
            or not approval["reviewer"].strip() or not approval["review_note"].strip()
            or approval["run_id"] != run["run_id"]
            or approval["source_id"] != source_id
            or approval["source_sha256"] != digest(source)
            or approval["payload_sha256"] != payload_hash):
        raise ValueError("human source/meaning approval must bind exact source and payload hashes")
    body = report(payload).encode("utf-8")
    stem = "action-register-" + payload_hash[:16]
    out_dir.mkdir(parents=True, exist_ok=True)
    target = out_dir / (stem + ".md")
    receipt = out_dir / (stem + ".receipt.json")
    record = {"run_id": run["run_id"], "source_id": source_id, "source_sha256": digest(source), "payload_sha256": payload_hash, "report_sha256": digest(body), "reviewer": approval["reviewer"], "operation": "local action-triage register"}
    encoded = (json.dumps(record, indent=2) + "\n").encode("utf-8")
    for path, data in [(target, body), (receipt, encoded)]:
        if path.exists():
            if path.read_bytes() != data:
                raise ValueError(f"uncertain prior output differs: {path}; inspect before retry")
        else:
            with path.open("xb") as handle:
                handle.write(data)
        if path.read_bytes() != data:
            raise IOError(f"readback failed: {path}")
    return target, receipt


def produce(source_id: str, source_path: Path, candidate_path: Path, model: str) -> None:
    from agents import Agent, Runner

    source = source_path.read_bytes()
    source_text = source.decode("utf-8")
    if not source_text.strip() or candidate_path.exists() or not candidate_path.parent.is_dir():
        raise ValueError("source must contain text; candidate must be new and its parent directory must already exist")
    run_id = str(uuid.uuid4())
    agent = Agent(
        name="Owned-source action extractor",
        model=model,
        instructions=("Extract only explicit action commitments from the supplied numbered source. "
                      "Return action-register/v1. Copy an exact quote with matching 1-based line span. "
                      "Use null for an unstated owner or due date; never infer them. "
                      "Use partial and needs_review for ambiguous evidence; blocked for unusable input. "
                      "Do not use tools or hand off."),
        output_type=Extraction,
    )
    numbered = "\n".join(f"{n}: {line}" for n, line in enumerate(source_text.splitlines(), 1))
    try:
        result = Runner.run_sync(agent, f"Source ID: {source_id}\nSHA-256: {digest(source)}\nNumbered source:\n{numbered}")
        if result.interruptions or result.last_agent is not agent or not isinstance(result.final_output, Extraction):
            raise ValueError("run interrupted, unfinished, or finished by unexpected agent; this CLI does not persist resumable RunState, so do not resume from its failure receipt")
        output = result.final_output
        if output.source_id != source_id or output.source_sha256 != digest(source):
            raise ValueError("producer returned wrong source identity")
        candidate = canonical(output)
        with candidate_path.open("xb") as handle:
            handle.write(candidate)  # Candidate only; consumer still requires review.
        receipt = {"run_id": run_id, "source_id": source_id, "source_sha256": digest(source), "payload_sha256": digest(candidate), "last_agent": result.last_agent.name, "model": model}
        with candidate_path.with_suffix(".run.json").open("x", encoding="utf-8") as handle:
            handle.write(json.dumps(receipt, indent=2) + "\n")
    except Exception as exc:
        failure = {"run_id": run_id, "source_id": source_id, "source_sha256": digest(source), "error_type": type(exc).__name__, "error": str(exc), "state": "unknown_or_blocked; inspect run and destination before retry"}
        with candidate_path.with_name(candidate_path.stem + "." + run_id + ".failure.json").open("x", encoding="utf-8") as handle:
            handle.write(json.dumps(failure, indent=2) + "\n")
        raise


def main() -> None:
    parser = argparse.ArgumentParser()
    sub = parser.add_subparsers(dest="command", required=True)
    for name in ("produce", "consume"):
        p = sub.add_parser(name)
        p.add_argument("--source-id", required=True)
        p.add_argument("--source", type=Path, required=True)
        if name == "produce":
            p.add_argument("--candidate", type=Path, required=True)
            p.add_argument("--model", required=True)
        else:
            p.add_argument("--candidate", type=Path, required=True)
            p.add_argument("--approval", type=Path, required=True)
            p.add_argument("--out", type=Path, required=True)
    args = parser.parse_args()
    if args.command == "produce":
        produce(args.source_id, args.source, args.candidate, args.model)
    else:
        print(*consume(args.source_id, args.source, args.candidate, args.approval, args.out), sep="\n")


if __name__ == "__main__":
    main()

Ограничения и источники

Контракт и локальный потребитель проверены офлайн пятью тестами и семью синтетическими сценариями имитации производителя или повтора. Проверялись строгая генерация схемы, отказ до создания файла, группировка, хеши и обработка ошибок. Не было отправки схемы поставщику, вызова модели, извлечения реальных заметок, фактического человеческого одобрения, отчёта из реального источника или измерения счёта и качества. Плата API зависит от аккаунта; перед отправкой проверьте совместимость, цены и разрешение на данные.

Для смежного контекста руководство BIG CHANGE по выбору ИИ-задачи из собственной работы рассматривает более раннее решение, что проверять. Этот материал начинается с выбранного разрешённого извлечения и передаёт один типизированный результат проверенному локальному потребителю.

Источники и дополнительные материалы

  • OpenAI Agents SDK: Agents описывает Pydantic output_type. В нём нет человеческого одобрения или локального потребителя этого рецепта.
  • OpenAI Agents SDK: Results описывает final_output, last_agent, прерывания и to_state(). Этот CLI не сохраняет возобновляемое состояние.
  • OpenAI Agents SDK: Quickstart рассматривает установку и ключ API. Храните ключ вне источника и квитанций.
  • OpenAI Structured Outputs объясняет строгую схему и поддержку моделей. Подтвердите поддержку выбранной модели.
  • Pydantic ConfigDict описывает строгую валидацию и запрет дополнительных полей.
  • OpenAI Agents SDK 0.24.0 и Pydantic 2.14.1 обозначают точные локально установленные версии. Принятие схемы поставщиком не проверялось.