第163回 AIの要確認結果を人へ回す:PythonとSQLiteで作るレビューキュー

第162回では、AIが返したJSONLの項目、型、許可カテゴリ、重複IDなどを検査しました。ただし、構造が正しいことと、判定内容が業務上正しいことは別です。needs_reviewがtrueの結果は、元データと照合してから確定します。

今回は、検証済みJSONLから要確認行だけをSQLiteへ取り込み、pending、accepted、corrected、rejectedの状態を管理します。Python標準ライブラリだけを使い、AIサービスへの通信や元データの更新は行いません。

今回作るレビューキューの契約

項目 仕様
入力 第162回と同じ4項目を持つ、BOMなしUTF-8の検証済みJSONL
登録対象 needs_reviewがtrueの行だけ
識別子 batch_idとrecord_idの組み合わせ。同じレコードを別バッチで再確認できる
カテゴリ データベース作成時に登録した許可カテゴリだけ
判定 提案を採用、許可カテゴリへ修正、または却下
再判定 このCLIでは確定済みの行を変更しない
書き込み バッチの取り込みと1件の判定を、それぞれSQLiteトランザクションで処理する
結果出力 ヘルプ表示を除き、出力に成功した場合は標準出力または標準エラー出力へ1行のJSONを返す

needs_review=falseの行は件数だけを数え、データベースには保存しません。検証済みJSONLは変更せず、別途保管します。

レビュー理由はAIの説明であり、正しさの証明ではありません。担当者はrecord_idを使い、権限のある元データと照合します。

SQLiteを使う範囲と制約

この実装は、1台の端末または少人数で扱う小規模なレビューキューを対象とします。複数人が同時に操作するWeb画面、認証、権限管理、改ざん検知は備えていません。--reviewerも利用者が入力する文字列であり、本人確認にはなりません。

  • Python 3.10以上を使用する
  • データベースはローカルディスクに置き、アクセス権とバックアップを管理する
  • 理由やレビュー注記に機密情報を入れる場合は、データベースとバックアップも同じ基準で保護する
  • カテゴリ方針を変えるときは、既存データとの整合性を確認して移行する。このスクリプトにはカテゴリ変更機能を設けない
  • 大人数での同時編集、ネットワーク共有上での利用、レビュー画面が必要な業務では、専用のデータベースとアプリケーションを検討する

1.動作確認用JSONLを用意する

次の架空データを、BOMなしUTF-8のvalid.jsonlとして保存します。1行に1つのJSONオブジェクトを書きます。

{"record_id":"C-001","category":"配送","needs_review":true,"reason":"配送先変更と到着日の質問が同時に含まれる"}
{"record_id":"C-002","category":"請求","needs_review":false,"reason":"請求書再発行の依頼が明確"}
{"record_id":"C-003","category":"配送","needs_review":true,"reason":"配送状況と請求額の両方に言及している"}

2.レビューキューを実装する

次のコードをreview_queue.pyとして保存します。

import argparse
import json
import os
import re
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path


SCHEMA_VERSION = 1
EXPECTED_KEYS = frozenset(
    {"record_id", "category", "needs_review", "reason"}
)
BATCH_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")

CREATE_STATEMENTS = (
    """
    CREATE TABLE categories (
        category TEXT NOT NULL PRIMARY KEY,
        CHECK (length(category) > 0)
    )
    """,
    """
    CREATE TABLE review_batches (
        batch_id TEXT NOT NULL PRIMARY KEY,
        imported_at TEXT NOT NULL,
        source_name TEXT NOT NULL,
        total_rows INTEGER NOT NULL CHECK (total_rows >= 0),
        queued_rows INTEGER NOT NULL CHECK (queued_rows >= 0),
        skipped_rows INTEGER NOT NULL CHECK (skipped_rows >= 0),
        CHECK (length(batch_id) > 0),
        CHECK (length(source_name) > 0),
        CHECK (total_rows = queued_rows + skipped_rows)
    )
    """,
    """
    CREATE TABLE review_items (
        batch_id TEXT NOT NULL,
        record_id TEXT NOT NULL,
        proposed_category TEXT NOT NULL,
        reason TEXT NOT NULL,
        source_line INTEGER NOT NULL CHECK (source_line >= 1),
        status TEXT NOT NULL
            CHECK (status IN ('pending', 'accepted', 'corrected', 'rejected')),
        final_category TEXT,
        reviewer TEXT,
        review_note TEXT,
        decided_at TEXT,
        PRIMARY KEY (batch_id, record_id),
        UNIQUE (batch_id, source_line),
        CHECK (length(record_id) > 0),
        CHECK (length(reason) > 0),
        CHECK (
            (
                status = 'pending'
                AND final_category IS NULL
                AND reviewer IS NULL
                AND review_note IS NULL
                AND decided_at IS NULL
            )
            OR
            (
                status = 'accepted'
                AND final_category IS NOT NULL
                AND final_category = proposed_category
                AND reviewer IS NOT NULL
                AND length(reviewer) > 0
                AND review_note IS NOT NULL
                AND length(review_note) > 0
                AND decided_at IS NOT NULL
            )
            OR
            (
                status = 'corrected'
                AND final_category IS NOT NULL
                AND final_category <> proposed_category
                AND reviewer IS NOT NULL
                AND length(reviewer) > 0
                AND review_note IS NOT NULL
                AND length(review_note) > 0
                AND decided_at IS NOT NULL
            )
            OR
            (
                status = 'rejected'
                AND final_category IS NULL
                AND reviewer IS NOT NULL
                AND length(reviewer) > 0
                AND review_note IS NOT NULL
                AND length(review_note) > 0
                AND decided_at IS NOT NULL
            )
        ),
        FOREIGN KEY (batch_id)
            REFERENCES review_batches(batch_id) ON DELETE RESTRICT,
        FOREIGN KEY (proposed_category)
            REFERENCES categories(category) ON DELETE RESTRICT,
        FOREIGN KEY (final_category)
            REFERENCES categories(category) ON DELETE RESTRICT
    )
    """,
)


class AppError(Exception):
    def __init__(self, error, details=None):
        super().__init__(error)
        self.error = error
        self.details = details


class JsonArgumentParser(argparse.ArgumentParser):
    def error(self, message):
        raise AppError("invalid_arguments", {"message": message})


def emit(stream, payload):
    line = json.dumps(
        payload,
        ensure_ascii=False,
        separators=(",", ":"),
    ) + "\n"
    stream.write(line)
    stream.flush()


def has_control_character(value):
    return any(
        ord(character) < 32 or ord(character) == 127
        for character in value
    )


def validate_plain_text(value, field):
    if (
        value == ""
        or value != value.strip()
        or has_control_character(value)
    ):
        raise AppError("invalid_text_argument", {"field": field})
    return value


def validate_batch_id(value):
    if BATCH_PATTERN.fullmatch(value) is None:
        raise AppError(
            "invalid_batch_id",
            {
                "rule": (
                    "1-64 ASCII characters; first is a letter or digit; "
                    "the rest may also include dot, underscore, or hyphen"
                )
            },
        )
    return value


def validate_categories(values):
    categories = []
    seen = set()

    for value in values:
        category = validate_plain_text(value, "category")
        if category in seen:
            raise AppError("duplicate_category")
        seen.add(category)
        categories.append(category)

    return categories


def limit_value(raw_value):
    try:
        value = int(raw_value)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("limit must be an integer") from exc

    if value < 1 or value > 500:
        raise argparse.ArgumentTypeError("limit must be between 1 and 500")
    return value


def utc_now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


def connect_database(path):
    return sqlite3.connect(
        path,
        timeout=5.0,
        isolation_level=None,
    )


def configure_connection(connection):
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    connection.execute("PRAGMA busy_timeout = 5000")


def safe_rollback(connection):
    try:
        connection.rollback()
    except sqlite3.Error:
        pass


def open_database(path):
    if not path.is_file():
        raise AppError("database_not_found", {"db": str(path)})

    connection = connect_database(path)
    try:
        configure_connection(connection)
        version = connection.execute("PRAGMA user_version").fetchone()[0]
        if version != SCHEMA_VERSION:
            raise AppError(
                "unsupported_schema_version",
                {"expected": SCHEMA_VERSION, "actual": version},
            )
    except BaseException:
        connection.close()
        raise

    return connection


def reject_constant(_value):
    raise ValueError("non-standard JSON number")


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate JSON key")
        result[key] = value
    return result


def parse_result(raw_line, line_number):
    try:
        item = json.loads(
            raw_line,
            parse_constant=reject_constant,
            object_pairs_hook=unique_object,
        )
    except (json.JSONDecodeError, ValueError) as exc:
        raise AppError("invalid_json", {"line": line_number}) from exc

    if type(item) is not dict:
        raise AppError("json_object_required", {"line": line_number})

    keys = set(item)
    if keys != EXPECTED_KEYS:
        raise AppError(
            "invalid_fields",
            {
                "line": line_number,
                "missing": sorted(EXPECTED_KEYS - keys),
                "unknown": sorted(keys - EXPECTED_KEYS),
            },
        )

    for field in ("record_id", "category", "reason"):
        value = item[field]
        if type(value) is not str or value.strip() == "":
            raise AppError(
                "invalid_field_value",
                {"line": line_number, "field": field},
            )

    record_id = item["record_id"]
    if (
        record_id != record_id.strip()
        or has_control_character(record_id)
    ):
        raise AppError(
            "invalid_field_value",
            {"line": line_number, "field": "record_id"},
        )

    if type(item["needs_review"]) is not bool:
        raise AppError(
            "invalid_field_value",
            {"line": line_number, "field": "needs_review"},
        )

    return item


def cmd_init(args):
    categories = validate_categories(args.category)
    if args.db.exists():
        raise AppError("database_already_exists", {"db": str(args.db)})

    connection = None
    created = False

    try:
        descriptor = os.open(
            args.db,
            os.O_CREAT | os.O_EXCL | os.O_WRONLY,
            0o600,
        )
        created = True
        os.close(descriptor)

        connection = connect_database(args.db)
        configure_connection(connection)
        connection.execute("BEGIN IMMEDIATE")

        for statement in CREATE_STATEMENTS:
            connection.execute(statement)

        connection.executemany(
            "INSERT INTO categories(category) VALUES (?)",
            ((category,) for category in categories),
        )
        connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
        connection.commit()
    except BaseException:
        if connection is not None:
            safe_rollback(connection)
            connection.close()
            connection = None
        if created:
            try:
                args.db.unlink()
            except OSError:
                pass
        raise
    finally:
        if connection is not None:
            connection.close()

    return {
        "ok": True,
        "command": "init",
        "db": str(args.db),
        "schema_version": SCHEMA_VERSION,
        "category_count": len(categories),
    }


def cmd_import_results(args):
    batch_id = validate_batch_id(args.batch_id)
    if not args.input.is_file():
        raise AppError("input_not_found", {"input": str(args.input)})

    connection = open_database(args.db)
    total_rows = 0
    queued_rows = 0
    skipped_rows = 0
    seen_record_ids = {}
    imported_at = utc_now()

    try:
        connection.execute("BEGIN IMMEDIATE")

        existing_batch = connection.execute(
            "SELECT 1 FROM review_batches WHERE batch_id = ?",
            (batch_id,),
        ).fetchone()
        if existing_batch is not None:
            raise AppError("batch_already_exists", {"batch_id": batch_id})

        connection.execute(
            """
            INSERT INTO review_batches(
                batch_id, imported_at, source_name,
                total_rows, queued_rows, skipped_rows
            ) VALUES (?, ?, ?, 0, 0, 0)
            """,
            (batch_id, imported_at, args.input.name),
        )

        allowed_categories = {
            row["category"]
            for row in connection.execute("SELECT category FROM categories")
        }

        try:
            with args.input.open("r", encoding="utf-8", newline="") as source:
                for line_number, raw_line in enumerate(source, start=1):
                    total_rows += 1
                    if raw_line.strip() == "":
                        raise AppError("blank_line", {"line": line_number})

                    item = parse_result(raw_line, line_number)
                    record_id = item["record_id"]

                    if item["category"] not in allowed_categories:
                        raise AppError(
                            "category_not_allowed",
                            {"line": line_number},
                        )

                    if record_id in seen_record_ids:
                        raise AppError(
                            "duplicate_record_id",
                            {
                                "line": line_number,
                                "first_line": seen_record_ids[record_id],
                            },
                        )
                    seen_record_ids[record_id] = line_number

                    if item["needs_review"] is False:
                        skipped_rows += 1
                        continue

                    connection.execute(
                        """
                        INSERT INTO review_items(
                            batch_id, record_id, proposed_category,
                            reason, source_line, status
                        ) VALUES (?, ?, ?, ?, ?, 'pending')
                        """,
                        (
                            batch_id,
                            record_id,
                            item["category"],
                            item["reason"],
                            line_number,
                        ),
                    )
                    queued_rows += 1
        except UnicodeDecodeError as exc:
            raise AppError("input_not_utf8") from exc

        if total_rows == 0:
            raise AppError("empty_input")

        connection.execute(
            """
            UPDATE review_batches
            SET total_rows = ?, queued_rows = ?, skipped_rows = ?
            WHERE batch_id = ?
            """,
            (total_rows, queued_rows, skipped_rows, batch_id),
        )
        connection.commit()
    except BaseException:
        safe_rollback(connection)
        raise
    finally:
        connection.close()

    return {
        "ok": True,
        "command": "import-results",
        "batch_id": batch_id,
        "total_rows": total_rows,
        "queued_rows": queued_rows,
        "skipped_rows": skipped_rows,
    }


def cmd_pending(args):
    batch_id = validate_batch_id(args.batch_id)
    connection = open_database(args.db)

    try:
        connection.execute("BEGIN")
        batch = connection.execute(
            "SELECT 1 FROM review_batches WHERE batch_id = ?",
            (batch_id,),
        ).fetchone()
        if batch is None:
            raise AppError("batch_not_found", {"batch_id": batch_id})

        pending_count = connection.execute(
            """
            SELECT COUNT(*)
            FROM review_items
            WHERE batch_id = ? AND status = 'pending'
            """,
            (batch_id,),
        ).fetchone()[0]

        rows = connection.execute(
            """
            SELECT record_id, proposed_category, reason, source_line
            FROM review_items
            WHERE batch_id = ? AND status = 'pending'
            ORDER BY source_line
            LIMIT ?
            """,
            (batch_id, args.limit),
        ).fetchall()
        connection.commit()
    except BaseException:
        safe_rollback(connection)
        raise
    finally:
        connection.close()

    items = [
        {
            "record_id": row["record_id"],
            "proposed_category": row["proposed_category"],
            "reason": row["reason"],
            "source_line": row["source_line"],
        }
        for row in rows
    ]

    return {
        "ok": True,
        "command": "pending",
        "batch_id": batch_id,
        "pending_count": pending_count,
        "returned_count": len(items),
        "items": items,
    }


def cmd_decide(args):
    batch_id = validate_batch_id(args.batch_id)
    record_id = validate_plain_text(args.record_id, "record_id")
    reviewer = validate_plain_text(args.reviewer, "reviewer")
    note = validate_plain_text(args.note, "note")

    requested_category = None
    if args.decision == "correct":
        if args.final_category is None:
            raise AppError("final_category_required")
        requested_category = validate_plain_text(
            args.final_category,
            "final_category",
        )
    elif args.final_category is not None:
        raise AppError(
            "final_category_not_allowed_for_decision",
            {"decision": args.decision},
        )

    connection = open_database(args.db)

    try:
        connection.execute("BEGIN IMMEDIATE")
        row = connection.execute(
            """
            SELECT proposed_category, status
            FROM review_items
            WHERE batch_id = ? AND record_id = ?
            """,
            (batch_id, record_id),
        ).fetchone()

        if row is None:
            raise AppError("review_item_not_found")
        if row["status"] != "pending":
            raise AppError(
                "review_item_already_decided",
                {"status": row["status"]},
            )

        if args.decision == "accept":
            status = "accepted"
            final_category = row["proposed_category"]
        elif args.decision == "correct":
            status = "corrected"
            final_category = requested_category
            if final_category == row["proposed_category"]:
                raise AppError("correction_must_change_category")

            allowed = connection.execute(
                "SELECT 1 FROM categories WHERE category = ?",
                (final_category,),
            ).fetchone()
            if allowed is None:
                raise AppError("final_category_not_allowed")
        else:
            status = "rejected"
            final_category = None

        cursor = connection.execute(
            """
            UPDATE review_items
            SET status = ?, final_category = ?, reviewer = ?,
                review_note = ?, decided_at = ?
            WHERE batch_id = ? AND record_id = ? AND status = 'pending'
            """,
            (
                status,
                final_category,
                reviewer,
                note,
                utc_now(),
                batch_id,
                record_id,
            ),
        )
        if cursor.rowcount != 1:
            raise AppError("concurrent_update_detected")

        connection.commit()
    except BaseException:
        safe_rollback(connection)
        raise
    finally:
        connection.close()

    return {
        "ok": True,
        "command": "decide",
        "batch_id": batch_id,
        "record_id": record_id,
        "status": status,
        "final_category": final_category,
    }


def build_parser():
    parser = JsonArgumentParser(description="SQLite review queue")
    subparsers = parser.add_subparsers(dest="command", required=True)

    init_parser = subparsers.add_parser("init")
    init_parser.add_argument("--db", type=Path, required=True)
    init_parser.add_argument(
        "--category",
        action="append",
        required=True,
        help="Repeat for every allowed category",
    )
    init_parser.set_defaults(handler=cmd_init)

    import_parser = subparsers.add_parser("import-results")
    import_parser.add_argument("--db", type=Path, required=True)
    import_parser.add_argument("--input", type=Path, required=True)
    import_parser.add_argument("--batch-id", required=True)
    import_parser.set_defaults(handler=cmd_import_results)

    pending_parser = subparsers.add_parser("pending")
    pending_parser.add_argument("--db", type=Path, required=True)
    pending_parser.add_argument("--batch-id", required=True)
    pending_parser.add_argument("--limit", type=limit_value, default=50)
    pending_parser.set_defaults(handler=cmd_pending)

    decide_parser = subparsers.add_parser("decide")
    decide_parser.add_argument("--db", type=Path, required=True)
    decide_parser.add_argument("--batch-id", required=True)
    decide_parser.add_argument("--record-id", required=True)
    decide_parser.add_argument(
        "--decision",
        choices=("accept", "correct", "reject"),
        required=True,
    )
    decide_parser.add_argument("--final-category")
    decide_parser.add_argument("--reviewer", required=True)
    decide_parser.add_argument("--note", required=True)
    decide_parser.set_defaults(handler=cmd_decide)

    return parser


def main():
    exit_code = 0
    stream = sys.stdout

    try:
        args = build_parser().parse_args()
        payload = args.handler(args)
    except AppError as exc:
        payload = {"ok": False, "error": exc.error}
        if exc.details is not None:
            payload["details"] = exc.details
        stream = sys.stderr
        exit_code = 2
    except KeyboardInterrupt:
        payload = {"ok": False, "error": "interrupted"}
        stream = sys.stderr
        exit_code = 130
    except sqlite3.Error as exc:
        payload = {
            "ok": False,
            "error": "database_error",
            "details": {"type": type(exc).__name__},
        }
        stream = sys.stderr
        exit_code = 3
    except (OSError, UnicodeError) as exc:
        payload = {
            "ok": False,
            "error": "io_error",
            "details": {"type": type(exc).__name__},
        }
        stream = sys.stderr
        exit_code = 3
    except Exception as exc:
        payload = {
            "ok": False,
            "error": "unexpected_error",
            "details": {"type": type(exc).__name__},
        }
        stream = sys.stderr
        exit_code = 3

    try:
        emit(stream, payload)
    except KeyboardInterrupt:
        return 130
    except (OSError, UnicodeError):
        return 3
    except Exception:
        return 3

    return exit_code


if __name__ == "__main__":
    sys.exit(main())

接続ではisolation_level=Noneを指定し、自動的なトランザクション開始に依存しません。読み取りはBEGIN、取り込みと判定は書き込みを先に確保するBEGIN IMMEDIATEで開始し、成功時だけ明示的にコミットします。

emit()はJSONと改行を一度に書き、続けてflush()します。成功結果を含む最終出力も例外処理の対象にするため、出力時のOSErrorやUnicodeErrorは終了コード3、出力中の割り込みは終了コード130になります。ただし、出力先が閉じている場合などは、JSONが出ないか途中までしか書かれないことがあります。

取り込み時にも項目、型、重複キーなどを確認します。ただし、第162回で設定した行サイズや文字数の上限を、このスクリプトが代替するわけではありません。第162回の検証を通過したvalid.jsonlだけを入力にします。

3.許可カテゴリを固定してデータベースを作る

同名のreview.dbが存在しないディレクトリで実行します。--categoryは必要な数だけ繰り返せます。

python review_queue.py init \
  --db review.db \
  --category 配送 \
  --category 請求 \
  --category その他

成功すると、標準出力へ次の1行が出ます。

{"ok":true,"command":"init","db":"review.db","schema_version":1,"category_count":3}

既存ファイルは上書きしません。作成時には排他的なファイル作成を使います。POSIX環境ではモード0600を指定し、実際の権限にはOSのumaskも適用されます。保存先のアクセス制御も確認してください。

4.検証済み結果をバッチとして取り込む

python review_queue.py import-results \
  --db review.db \
  --input valid.jsonl \
  --batch-id 2026-10-02-a

サンプルでは3行を読み、2行をレビュー対象として登録します。

{"ok":true,"command":"import-results","batch_id":"2026-10-02-a","total_rows":3,"queued_rows":2,"skipped_rows":1}

バッチIDの重複、空行、不正なJSON、重複したrecord_id、未知のカテゴリが1件でもあれば、バッチ全体をロールバックします。一部の行だけが登録された状態にはしません。

5.未確認の項目を取得する

python review_queue.py pending \
  --db review.db \
  --batch-id 2026-10-02-a \
  --limit 50

出力は1つのJSONオブジェクトです。理由に改行などが含まれていても、JSON文字列としてエスケープされます。

{"ok":true,"command":"pending","batch_id":"2026-10-02-a","pending_count":2,"returned_count":2,"items":[{"record_id":"C-001","proposed_category":"配送","reason":"配送先変更と到着日の質問が同時に含まれる","source_line":1},{"record_id":"C-003","proposed_category":"配送","reason":"配送状況と請求額の両方に言及している","source_line":3}]}

pending_countは未確認の総数、returned_countは今回返した件数です。総数が上限を超える場合は、表示された項目を判定してから同じコマンドを再実行します。

6.採用、修正、却下を記録する

1つの項目に実行できる判定は1回だけです。ここでは採用例をC-001に実行した後、C-003について「修正」または「却下」のどちらか一方を選びます。C-003の2つの例は相互に排他的であり、同じデータベースへ続けて実行する例ではありません。

AIの提案カテゴリを採用する

python review_queue.py decide \
  --db review.db \
  --batch-id 2026-10-02-a \
  --record-id C-001 \
  --decision accept \
  --reviewer sato \
  --note 原文照合済み
{"ok":true,"command":"decide","batch_id":"2026-10-02-a","record_id":"C-001","status":"accepted","final_category":"配送"}

C-003を別の許可カテゴリへ修正する場合

python review_queue.py decide \
  --db review.db \
  --batch-id 2026-10-02-a \
  --record-id C-003 \
  --decision correct \
  --final-category 請求 \
  --reviewer sato \
  --note 請求内容を主題として確定
{"ok":true,"command":"decide","batch_id":"2026-10-02-a","record_id":"C-003","status":"corrected","final_category":"請求"}

correctでは、登録済みの許可カテゴリだけを指定できます。提案と同じカテゴリは、修正にならないため拒否します。

C-003を却下する場合

次の例を試す場合は、C-003の修正例を実行しないでください。すでに修正例を実行した場合は、別名の新しいデータベースを作成し、手順3と4をそのデータベース名に置き換えて実行してから、次のコマンドでも同じ名前を指定します。

却下では--decision rejectを指定し、--final-categoryは付けません。保存される最終カテゴリはNULLになり、JSON出力ではnullになります。

python review_queue.py decide \
  --db review.db \
  --batch-id 2026-10-02-a \
  --record-id C-003 \
  --decision reject \
  --reviewer sato \
  --note 元データだけでは分類不能
{"ok":true,"command":"decide","batch_id":"2026-10-02-a","record_id":"C-003","status":"rejected","final_category":null}

いずれの判定も、対象がpendingである場合に一度だけ実行できます。確定済みレコードへの再実行は終了コード2で停止し、保存済みの判定を変更しません。

終了コードと失敗時の扱い

終了コード 意味 確認事項
0 コマンド処理と結果出力が成功 成功JSONの内容を確認する
2 引数、入力、カテゴリ、状態遷移の契約違反 入力内容や現在の状態を直す
3 ファイル操作、データベース、結果出力、または予期しない実行エラー 成功JSONがなければ、再実行前にデータベースの状態を確認する
130 処理中または結果出力中の割り込み 再実行前にデータベースの状態を確認する

処理中の例外では未確定のトランザクションをロールバックします。ただし、コミット直後や結果出力中に入出力エラーや割り込みが起きた場合、データベースへの反映が完了していても、呼び出し側が完全な成功JSONを受け取れないことがあります。出力先そのものが利用できない場合は、エラーJSONも出力できません。終了コードだけで未反映と決めず、同じbatch_idや対象レコードを照会してから再実行します。

SQLiteの待機時間は5秒です。別プロセスが書き込みを保持し続けた場合は、データベースエラーとして終了します。実行中の別作業を確認してから再試行します。

運用へ入れる前の確認

  1. 第162回の検証ゲートを通過したJSONLだけを入力にする
  2. カテゴリ一覧を業務担当者と確認してからデータベースを作る
  3. 処理単位ごとに重複しないbatch_idを付ける
  4. AIの理由だけで判断せず、権限のある元データを照合する
  5. pending_countが0になったことを確認する
  6. データベースを元データと同じ機密区分で保護し、必要な期間だけ保存する

pending_count=0が示すのは、そのバッチの要確認行に判定が付いたことだけです。自動判定された行の正しさや、後続システムへの反映完了までは保証しません。

まずはサンプル用の新しいデータベースで、取り込み、一覧取得、判定、再判定の拒否までを確認します。その後、業務データへ適用してください。

仕様を確認する一次資料