Перейти к содержанию

Черновик только после серверного подтверждения

До этого сервер только читал материалы. Теперь добавим узкую запись: создать неопубликованный черновик в локальной SQLite. Для этого нужны editor-роль и отдельное подтверждение человеком точного содержимого. После урока инструмент не сможет создать черновик только по произвольному пожеланию модели. Сервер проверит токен подтверждения, отпечаток payload, срок действия и одноразовость перед изменением базы.

Новая операция называется library.create_draft. Она не публикует статью, не меняет исходный корпус и не записывает в рабочий ProfessorWeb. Черновик хранится в таблице drafts учебной базы .state/editor.sqlite3. При подготовке база не создавалась исполнением примера, токены не выдавались, программы не запускались. Файлы proposal.json и operator.py являются самостоятельными авторскими исходниками для разбора механизма будущего действия.

Человек подтверждает предмет

Сначала готовится предложение с slug, title и text. Оператор открывает его и оценивает точный текст, который будет сохранён. Отдельная программа operator.py без специального флага только показывает содержимое и прекращает действие. С флагом явного подтверждения она выдаёт случайный токен на пять минут. Такая операция не входит в MCP tools: агент не получает инструмент «одобрить собственный черновик», который обесценил бы участие человека.

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

Токен создаётся через secrets и хранится в базе только как SHA-256-отпечаток. Это снижает риск раскрытия готового значения из таблицы подтверждений, но не заменяет контроль доступа к файлу базы. Токен является краткоживущим секретом, который передаётся в аргументе записи. Его не следует включать в обычную диагностику, примеры публичных логов или архив передачи. В нашей документации отсутствует выдуманный полученный токен; показана только схема его использования.

Подтверждение связано с содержимым

Функция fingerprint сериализует kind, slug, title и text в устойчивый JSON и считает SHA-256. Подтверждение хранит этот отпечаток. Если после одобрения модель изменит заголовок либо одну строку текста, сервер получит другой payload и откажет. Таким образом, человек одобряет определённый предмет, а не неограниченное «право написать что-нибудь». Поле kind также связывает подтверждение с типом действия, чтобы оно не стало общим разрешением на будущие операции.

Сам fingerprint не удостоверяет авторство и не делает текст полезным. Он лишь позволяет проверить совпадение с тем, что было предъявлено оператору. Ошибка или нежелательное содержание в одобренном документе останутся возможными. Поэтому оператор должен видеть полное предложение, а не только размер или короткое резюме модели. Для больших материалов может понадобиться удобный просмотр, но проверка сервера всё равно должна связываться с точным сохраняемым представлением.

Схема записи ограничивает slug, заголовок и текст. Slug допускает заданную форму латинских сегментов, а размер текста ограничен. Эти условия предотвращают произвольный путь и случайно огромный запрос. Поскольку мы сохраняем черновик в таблице, slug не становится именем открываемого клиентом файла. Публичный permalink и включение в каталог отсутствуют в этой операции. Добавлять их следовало бы отдельным редакционным процессом с собственными условиями публикации.

Проверка и запись вместе

create_draft сначала проверяет editor, затем открывает транзакцию BEGIN IMMEDIATE. В ней читается подтверждение, проверяются payload, срок и used, после чего вставляется черновик и помечается использование токена. Всё завершается commit. Если вставка не прошла, выполняется rollback. Такое объединение важно: нельзя потребить подтверждение и оставить полузавершённое состояние без черновика либо записать черновик, но сохранить токен готовым для повторного нового действия.

У таблицы drafts slug уникален. Если уже существует черновик с таким именем, операция не перезаписывает его молча. Это предметный отказ, который инструмент возвращает как isError. На данном шаге повтор операции также не считается успешным воспроизведением: прежний token использован, и новая запись не должна появиться. В следующем уроке добавим понятный договор повтора по отдельному идентификатору операции, сохраняя запрет нового содержания под старым ключом.

Срок сравнивается с текущим временем во время обработки. Одобрение, выданное давно, не остаётся вечным правом. При этом локальные часы являются частью доверенной среды нашего примера. Если требуется распределённая инфраструктура или строгая аудитируемая политика, нужно учитывать синхронизацию времени и хранение подтверждений. Мы не создаём такую систему здесь; пяти минут достаточно, чтобы объяснить краткоживущий допуск конкретной операции в небольшой лаборатории.

Подтверждение изменённого предложения

Допустим, оператор одобрил заголовок «Учебный черновик», а модель затем добавила к нему обещание, которого в тексте нет. Даже при совпадении slug это другой payload. Сервер должен отказать и потребовать новое точное предложение. Такой сценарий показывает, зачем fingerprint включает не только идентификатор, но и текст с заголовком. Одобрение нельзя считать общим разрешением редактировать материал до отправки по усмотрению исполнителя.

Изменение operation само по себе не меняет отпечаток содержимого, но токен одноразовый. На девятом шаге один успешный commit потребляет его. Если ответ потерялся, простой новый запрос не получает второй черновик автоматически. Следующий урок добавит отдельное правило replay, которое возвращает существующий результат, сохраняя запрет изменения содержания. Эти механизмы связаны, но решают разные вопросы: допуск и повтор доставки.

SQLite хранит неопубликованный текст, а не готовый Markdown в content/articles. Поэтому результат draft=true не доказывает появление страницы даже на локальном предпросмотре. Для экспорта и редакционного включения нужен отдельный процесс. В нашем инструменте такого побочного действия нет, и скрыто запускать сборку после INSERT нельзя.

Ожидаемый корректный маршрут состоит из просмотра предложения человеком, получения токена отдельным действием и одного editor-вызова записи. Изменённый текст, reader, истёкший или использованный токен должны привести к отказу. Ни один маршрут не выполнялся при подготовке. Сохранённый черновик не появляется в documents, потому что корпус читается только из манифеста. Такая граница оставляет будущую публикацию самостоятельным решением, а не побочным эффектом AI-инструмента.

Полный файл storage.py из состояния lesson-09. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

from pathlib import Path
import hashlib
import json
import secrets
import sqlite3
import time
from library import ROOT, ROLE, DomainError

DB = ROOT / ".state" / "editor.sqlite3"

def fingerprint(slug, title, text):
    value = {"kind": "create_draft", "slug": slug, "title": title, "text": text}
    return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()

def connection():
    DB.parent.mkdir(mode=0o700, exist_ok=True)
    db = sqlite3.connect(DB, timeout=5, isolation_level=None)
    db.execute("CREATE TABLE IF NOT EXISTS approvals (digest TEXT PRIMARY KEY, payload TEXT NOT NULL, expires INTEGER NOT NULL, used INTEGER NOT NULL DEFAULT 0)")
    db.execute("CREATE TABLE IF NOT EXISTS drafts (slug TEXT PRIMARY KEY, title TEXT NOT NULL, text TEXT NOT NULL, operation TEXT UNIQUE NOT NULL, payload TEXT NOT NULL)")
    return db

def approve(slug, title, text):
    token = secrets.token_urlsafe(32)
    digest = hashlib.sha256(token.encode()).hexdigest()
    db = connection()
    try:
        db.execute("INSERT INTO approvals(digest,payload,expires) VALUES(?,?,?)", (digest, fingerprint(slug,title,text), int(time.time())+300))
    finally:
        db.close()
    return token

def create_draft(slug, title, text, approval, operation, allow_retry):
    if ROLE != "editor":
        raise DomainError("write not allowed for this role")
    payload = fingerprint(slug,title,text)
    digest = hashlib.sha256(approval.encode()).hexdigest()
    db = connection()
    try:
        db.execute("BEGIN IMMEDIATE")
        old = db.execute("SELECT slug,payload FROM drafts WHERE operation=?", (operation,)).fetchone()
        if old is not None:
            if not allow_retry or old[1] != payload:
                raise DomainError("operation already used or payload differs")
            db.commit()
            return {"slug": old[0], "draft": True, "replayed": True}
        permit = db.execute("SELECT payload,expires,used FROM approvals WHERE digest=?", (digest,)).fetchone()
        if permit is None or permit[0] != payload or permit[1] <= int(time.time()) or permit[2] != 0:
            raise DomainError("confirmation missing, expired, changed or used")
        db.execute("INSERT INTO drafts(slug,title,text,operation,payload) VALUES(?,?,?,?,?)", (slug,title,text,operation,payload))
        db.execute("UPDATE approvals SET used=1 WHERE digest=?", (digest,))
        db.commit()
        return {"slug": slug, "draft": True, "replayed": False}
    except sqlite3.IntegrityError:
        db.rollback()
        raise DomainError("draft already exists") from None
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

Полный файл operator.py из состояния lesson-09. Полное самостоятельное состояние находится в архиве серии; фрагмент не заменяет остальные определения.

import argparse
import json
from pathlib import Path
from storage import approve

if __name__ == "__main__":
    parser=argparse.ArgumentParser(description="Operator-only confirmation; never an MCP tool")
    parser.add_argument("proposal")
    parser.add_argument("--confirm-exact-content",action="store_true")
    args=parser.parse_args()
    value=json.loads(Path(args.proposal).read_text(encoding="utf-8"))
    if set(value) != {"slug","title","text"}:
        raise ValueError("proposal must have exactly slug, title and text")
    if not all(isinstance(value[k],str) for k in value):
        raise ValueError("proposal values must be strings")
    if not args.confirm_exact_content:
        print(json.dumps(value,ensure_ascii=False,indent=2))
        raise SystemExit("Review above contents; explicit operator confirmation required")
    print(approve(**value))

Статус примера: not_run. При подготовке не выполнялись учебные программы, тесты, вызовы AI или деплой. Описанные эффекты ожидаемые.

Оглавление серии · Полные исходники

Первичные документы: Источник 1, Источник 2.