1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLMに「一言一句そのまま引用して」と頼んでも無駄な理由と、捏造引用をコードで弾く方法

1
Posted at

TL;DR

  1. 「元テキストから一言一句変えずに引用せよ」とプロンプトで命じても、LLM は引用を「抽出」せず「生成」するため、語尾改変・文の接合・捏造は防げない。
  2. 引用が本文に実在するかは、AI ではなく決定論的なコード(正規化 → 完全一致 → スライディングウィンドウ)で判定する。波ダッシュ・文中改行・全角半角など和文特有の表記ゆれも吸収する。
  3. 照合に落ちた根拠は LLM にリトライさせず、その場で破棄する。破棄率はメトリクスとして監視する。

この記事のコードは外部依存ゼロ・コメント込み約160行で、Vitest のテスト付きです。そのままコピーして使えます。


はじめに:その quote、本当に本文にありますか?

RAG や自律調査エージェントで「根拠付きの要約」を作るとき、こんな出力スキーマを書いたことがあると思います。

{
  "summary": "分かったことの要約",
  "quote": "その根拠となる本文の一節(必ず元テキストから一言一句変えずに抜き出すこと)"
}

そして返ってきた quote を、画面に「原文からの引用」として表示したり、後段の検証プロンプトに渡したりしていないでしょうか。

LLM が出力した quote を照合なしで信じるのは危険です。 プロンプトでどれだけ強く指示しても、モデルは次のような「それっぽい引用」を平然と返してきます。

パターン 例
語尾・ニュアンスの改変 「〜の可能性がある」→「〜である」
都合のいい接合 離れた2文をつなげて1文にする
完全な捏造 文脈には沿っているが、本文のどこにも存在しない文

しかも根拠として採用された引用は、後段で「事実」として扱われるため、誤りがパイプライン全体に伝播します。


なぜプロンプトでは防げないのか

LLM にとって quote を埋める行為は、「本文の部分文字列をスライスすること」ではありません。**「この文脈で引用として最も自然なトークン列を生成すること」**です。実際のモデルはかなり正確に書き写しますが、それは確率的に高い精度が出ているだけで、一字一句の正確さが保証されているわけではありません。数件に1件のズレでも、根拠データとしては致命的です。

「なら本文中の位置(offset)を返させればいい」と考えたくなりますが、offset も同じく生成された数字です。結局、照合は必要になります。

ここで重要なのは、LLM が生む「ズレ」には性質の異なる2種類があるという点です。

種類 例 扱い
表記ゆれ(意味は同じ) 全角/半角、〜と~、“”と""、実体参照、HTML抽出時の文中改行 正規化で吸収し、合格させる
内容の改変(意味が変わる) 数値・語尾の書き換え、文の接合、捏造 不合格にして破棄する

設計の要点は、正規化の範囲を「意味が変わらない差」に限定することです。正規化を緩めすぎると改変まで通してしまい、厳しすぎると正しい引用まで落とします。

なお、送り仮名の揺れ(「行なう」→「行う」)のように正規化で吸収しきれない差は、不合格側に倒します。正しい引用を少し取りこぼすコストは、捏造を通すコストより圧倒的に小さいからです(理由は後述の「なぜリトライしないのか」で)。


解決策:決定論的照合の流れ

[LLMが出力した quote]
         │
         ▼
 0. 前提チェック ── 空 → EMPTY / 短すぎる → TOO_SHORT
         │
         ▼
 1. 正規化(実体参照・NFKC・引用符/ダッシュ/波ダッシュ・空白)
         │
         ▼
 2. 存在判定:正規化した本文全体に含まれるか?
    └── NO ──> NOT_FOUND(破棄)
         │ YES
         ▼
 3. 局所性判定:連続する 1〜3 文の窓に収まるか?(スライディングウィンドウ)
    ├── YES ──> 合格(窓の「原文」をスニペットとして返す)
    └── NO  ──> SPAN_TOO_LONG(破棄)

ステップ2と3の役割分担がポイントです。

  • ステップ2(存在判定):「本文に実在するか」を判定します。本文全体に無い文字列は、どの窓にも無いので、ここで即座に落とします。
  • ステップ3(局所性判定):「1つの根拠として妥当な範囲か」を判定します。本文の半分をまるごと貼り付けたような quote は、実在はしても「根拠」としては不適切です。さらに、マッチした窓の原文が手に入るので、UI でのハイライトやログ出力にそのまま使えます。

正規化後の文字列はオフセットが原文とずれるため、「本文のどこにマッチしたか」を原文で示すには、原文を文単位に区切った窓で照合するのが素直な方法です。


実装(TypeScript・ゼロ依存)

verifyQuote.ts
// ---------- 型定義 ----------

export type RejectReason =
  | 'EMPTY' //          正規化後に空文字
  | 'TOO_SHORT' //      短すぎて「偶然の一致」と区別できない
  | 'NOT_FOUND' //      本文のどこにも存在しない(捏造・改変)
  | 'SPAN_TOO_LONG'; // 本文には存在するが、許容する文数を超えて跨いでいる

/** 判別可能なユニオン型: isValid で分岐すると matchedSnippet / reason が型で保証される */
export type QuoteVerificationResult =
  | { isValid: true; matchedSnippet: string }
  | { isValid: false; reason: RejectReason };

export interface VerifyOptions {
  /** これ未満の文字数(正規化後・コードポイント単位)の引用は TOO_SHORT で弾く */
  minLength?: number;
  /** 引用が跨いでよい連続文の最大数(=スライディングウィンドウの最大幅) */
  maxSentences?: number;
}

// ---------- 正規化 ----------

const NAMED_ENTITIES: Record<string, string> = {
  amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ',
};

/**
 * HTML実体参照を「1パスで」デコードする。
 * replace を連鎖させると `&amp;lt;` → `&lt;` → `<` のように二重デコードされるため。
 */
function decodeHtmlEntities(text: string): string {
  return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body: string) => {
    if (body[0] === '#') {
      const isHex = body[1] === 'x' || body[1] === 'X';
      const cp = parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
      return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : match;
    }
    return NAMED_ENTITIES[body.toLowerCase()] ?? match;
  });
}

/** 日本語(CJK)の文字・記号。これに隣接する空白は「意味のない空白」とみなして除去する */
const CJK = '[\\u3000-\\u30ff\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\uff00-\\uffef]';
const SPACE_AROUND_CJK = new RegExp(` (?=${CJK})|(?<=${CJK}) `, 'g');

/**
 * 照合用の正規化。「表記ゆれ」だけを吸収し、「内容の違い」は残す。
 * 本文と引用の両方に同じ関数を適用するのがポイント。
 */
export function normalizeForVerification(text: string): string {
  return decodeHtmlEntities(text)
    .normalize('NFKC') //                       全角英数→半角、半角カナ→全角、~(FF5E)→~ など
    .replace(/[“”„‟″‶]/g, '"') //               二重引用符の統一
    .replace(/[‘’‚‛′‵]/g, "'") //               一重引用符の統一
    .replace(/[‐‑‒–—―−]/g, '-') //              ダッシュ・マイナスの統一(長音「ー」は含めない)
    .replace(/〜/g, '~') //                     波ダッシュ(NFKC で変換されない)
    .replace(/\s+/g, ' ') //                    改行・タブ・全角空白を半角空白1つに圧縮
    .replace(SPACE_AROUND_CJK, '') //           日本語の途中に挟まった改行・空白を除去
    .trim()
    .toLowerCase();
}

// ---------- 文分割 ----------

/** 欧文は「終端記号+空白」、和文は「。!?」の直後を文境界とする */
const SENTENCE_BOUNDARY = /(?<=[.!?。!?])\s+|(?<=[。!?])/g;

/** 原文上の [開始, 終了) オフセットで文を返す(スニペットを原文のまま切り出すため) */
function splitSentenceSpans(text: string): Array<[number, number]> {
  const spans: Array<[number, number]> = [];
  let start = 0;
  for (const m of text.matchAll(SENTENCE_BOUNDARY)) {
    const end = m.index!;
    if (text.slice(start, end).trim()) spans.push([start, end]);
    start = end + m[0].length;
  }
  if (text.slice(start).trim()) spans.push([start, text.length]);
  return spans;
}

// ---------- 照合本体 ----------

/**
 * 本文1件に対する検証関数を作る。
 * 本文の正規化とウィンドウ生成は最初の1回だけ行い、引用ごとの照合は軽量に保つ。
 */
export function createQuoteVerifier(rawBody: string, options: VerifyOptions = {}) {
  const { minLength = 8, maxSentences = 3 } = options;

  const normBody = normalizeForVerification(rawBody);
  const spans = splitSentenceSpans(rawBody);

  // 幅 1 → maxSentences の順に並べる(=最小の窓が先にヒットする)
  const windows: Array<{ raw: string; norm: string }> = [];
  for (let size = 1; size <= maxSentences; size++) {
    for (let i = 0; i + size <= spans.length; i++) {
      const raw = rawBody.slice(spans[i][0], spans[i + size - 1][1]).trim();
      windows.push({ raw, norm: normalizeForVerification(raw) });
    }
  }

  return (quote: string): QuoteVerificationResult => {
    const normQuote = normalizeForVerification(quote);

    if (!normQuote) return { isValid: false, reason: 'EMPTY' };
    if ([...normQuote].length < minLength) return { isValid: false, reason: 'TOO_SHORT' };

    // 1. 存在判定: 本文全体に無ければ、どの窓にも無い。ここで即不合格。
    if (!normBody.includes(normQuote)) return { isValid: false, reason: 'NOT_FOUND' };

    // 2. 局所性判定: 連続 1〜maxSentences 文の窓に収まるか。
    //    収まった窓の「原文」を返すので、UI のハイライトやログにそのまま使える。
    const hit = windows.find((w) => w.norm.includes(normQuote));
    return hit
      ? { isValid: true, matchedSnippet: hit.raw }
      : { isValid: false, reason: 'SPAN_TOO_LONG' };
  };
}

/** 単発で使う場合のショートハンド */
export function verifyQuoteExistence(
  rawBody: string,
  quote: string,
  options?: VerifyOptions,
): QuoteVerificationResult {
  return createQuoteVerifier(rawBody, options)(quote);
}

// ---------- パイプライン用フィルター ----------

export interface EvidenceItem {
  sourceUrl: string;
  summary: string;
  quote: string;
}

export interface DiscardedEvidence {
  item: EvidenceItem;
  reason: RejectReason;
}

/**
 * 同一ページ(rawBody)から抽出された根拠を検証し、合格/破棄に振り分ける。
 * 破棄側も返すのは、破棄率をメトリクスとして監視するため。
 */
export function filterVerifiedEvidence(
  rawBody: string,
  items: EvidenceItem[],
  options?: VerifyOptions,
): { verified: EvidenceItem[]; discarded: DiscardedEvidence[] } {
  const verify = createQuoteVerifier(rawBody, options);
  const verified: EvidenceItem[] = [];
  const discarded: DiscardedEvidence[] = [];

  for (const item of items) {
    const result = verify(item.quote);
    if (result.isValid) verified.push(item);
    else discarded.push({ item, reason: result.reason });
  }
  return { verified, discarded };
}

実装のポイント

判別可能なユニオン型
isValid で分岐すれば、true 側では matchedSnippet が、false 側では reason が型レベルで保証されます。if (r.isValid) r.matchedSnippet と書いても undefined チェックが不要です。

実体参照は1パスでデコードする
.replace(/&amp;/g, '&').replace(/&lt;/g, '<') のように連鎖させると、&amp;lt; が < まで二重にデコードされます。本文に「&lt; という文字列そのもの」が書かれた技術記事などで誤判定の原因になるため、正規表現1本でまとめて処理しています。

長音「ー」はダッシュとして扱わない
ダッシュ類の統一に ー(U+30FC)を含めると、「プラットフォーム」が「プラットフォ-ム」になります。本文と引用の両方に適用されるので照合自体は通りますが、意図しない同一視の原因になるため除外しています。

波ダッシュは NFKC では揃わない
~(U+FF5E 全角チルダ)は NFKC で ~ になりますが、〜(U+301C 波ダッシュ)は変換されません。Windows 由来の文書とそれ以外で混在しやすい文字なので、明示的に統一しています。

日本語に挟まった空白・改行を除去する
HTML からテキストを抽出すると、<br> やソースの折り返しで文の途中に改行が入ることがあります。単純に「空白を1つに圧縮」するだけでは、本文が 手数料は 従来の、引用が 手数料は従来の となって正しい引用が落ちます。そこで、日本語文字に隣接する空白だけを除去しています(英単語間の空白は保持します)。

大文字小文字は区別しない
最後の toLowerCase() で、PayNext と paynext を同一視しています。英語の略語(US と us)のように意味が変わる場合もありますが、引用の照合では「LLM が大文字小文字だけを変えた」ケースのほうが多いため吸収側に倒しています。厳密にしたい場合は外してください。

文字数はコードポイント単位で数える
str.length は UTF-16 のコード単位数なので、絵文字やサロゲートペアを含むと実際の文字数とずれます。[...str].length で数えています。

本文の前処理は1回だけ
同じページから10件の根拠を抽出した場合、引用ごとに本文を正規化し直すのは無駄です。createQuoteVerifier は本文の正規化と窓の生成を最初に1回だけ行い、照合関数を返します。


パイプラインへの組み込み:落ちたら即破棄

照合関数を、LLM パイプラインの途中に決定論的なフィルターとして置きます。

const { verified, discarded } = filterVerifiedEvidence(rawBody, extractedItems);

for (const { item, reason } of discarded) {
  logger.warn('evidence_discarded', { reason, url: item.sourceUrl, quote: item.quote });
}

// 後段(要約・検証プロンプト)には verified だけを渡す
await synthesize(verified);

filterVerifiedEvidence が破棄側も返すのは、破棄率を監視するためです。モデルやプロンプトを変更した直後に NOT_FOUND の割合が跳ね上がったら、それは捏造が増えたシグナルです。照合は防御であると同時に、LLM の挙動の回帰を検知するセンサーにもなります。

なぜ LLM に「修正リトライ」をさせないのか

「照合に落ちたら『引用が本文にありません。抜き出し直してください』と再試行させればいいのでは?」と考えるかもしれません。本番運用では、リトライせず即時破棄を推奨します。

  1. リトライしても直らないケースが多い
    経験上、照合に落ちる引用の多くは、そもそも本文に該当する記述が無い(モデルの推論が飛躍している)ケースです。再試行しても、別の言い回しで同じ捏造を繰り返しがちで、レイテンシと API コストだけが増えます。
  2. 数件落としても全体の品質は保てる
    調査パイプラインでは、通常は複数ドメイン・複数ページから多数の根拠を集めます。照合に落ちた1〜2件を機械的に切り捨てても、残りの検証済み根拠で十分に正確なサマリを組み立てられます。
  3. 「誤って捨てる」より「誤って通す」ほうが高くつく
    正しい引用を1件取りこぼしても、出力の網羅性が少し下がるだけです。一方、捏造を1件通すと、それが「根拠付きの事実」として最終出力に混入します。非対称なコストに合わせて、判定は厳しい側に倒します。

このパターンが保証しないこと

照合で確かめられるのは**「引用が本文に実在するか」だけ**です。次の2つは保証しません。

  1. 要約が引用に支えられているか
    引用は本物でも、summary 側で引用に書かれていないことを主張している(=要約のハルシネーション)ケースは素通りします。要約と引用の整合性は、別の層(含意判定モデルや、LLM による「この引用からこの要約が言えるか」の判定など)で扱う必要があります。照合は、その判定の前提となる引用を本物に限定するための土台です。
  2. 引用が根拠として意味を持つか
    本文に実在する一般的な言い回しは通ります。たとえば「そう考えられています」(10文字)は、本文にあれば合格します。minLength だけでは防ぎきれないので、プロンプト側で「固有名詞・数値・日付のいずれかを含む一節を引用すること」と指定しておくと効果的です。

つまりこの照合は、ハルシネーション対策の「全部」ではなく、捏造された引用が後段に流れ込む経路を決定論的に塞ぐ1層です。

API の引用機能を使えば不要では?

主要な LLM API には、渡したドキュメント内の引用箇所を返す機能も登場しています。使える場面では有力な選択肢ですが、それでも自前の照合層を持つ価値はあります。

  • モデル・プロバイダに依存しない:モデルを変更しても、検証ロジックは変わりません。
  • 自分で取得した任意の本文に使える:スクレイピングや前処理を経たテキストなど、パイプラインの途中で生まれた本文にも適用できます。
  • 独立した検証層になる:生成した側とは別のコードで確かめるので、「生成側が正しいと言っている」ことに依存しません。

テスト(Vitest)

改変・接合・捏造を確実に落とし、表記ゆれは確実に通すことを確認するテストです。Jest でも import 行を変えるだけで動きます。

verifyQuote.test.ts
import { describe, it, expect } from 'vitest';
import { verifyQuoteExistence, filterVerifiedEvidence } from './verifyQuote';

// HTML から抽出した本文を想定(インデント・文中改行・実体参照・全角記号が混在)
const mockBody = `
    当社は2026年4月1日をもって、新決済プラットフォーム「PayNext」の提供を開始いたします。
    本サービスにより、加盟店の決済手数料は従来の3.2%から
    一律1.5%へと引き下げられます。
    なお、既存の加盟店様におかれましては、特別な移行手続きは一切不要です。
    受付時間は平日9時〜17時です。詳細は &quot;FAQ&quot; ページをご覧ください。
`;

describe('verifyQuoteExistence', () => {
  describe('合格すべきケース', () => {
    it('完全一致の引用は合格し、原文のスニペットを返す', () => {
      const r = verifyQuoteExistence(mockBody, '新決済プラットフォーム「PayNext」の提供を開始いたします。');
      expect(r.isValid).toBe(true);
      // 正規化後の文字列ではなく原文(大文字・長音「ー」も保持)が返る
      if (r.isValid) expect(r.matchedSnippet).toContain('プラットフォーム「PayNext」');
    });

    it('本文側の「文中改行」は無視して照合できる', () => {
      const r = verifyQuoteExistence(mockBody, '加盟店の決済手数料は従来の3.2%から一律1.5%へと引き下げられます。');
      expect(r.isValid).toBe(true);
    });

    it('隣り合う2文を跨ぐ引用は合格する', () => {
      const r = verifyQuoteExistence(mockBody, '一律1.5%へと引き下げられます。なお、既存の加盟店様におかれましては');
      expect(r.isValid).toBe(true);
    });

    it('全角/半角・波ダッシュ・引用符・実体参照のゆれを吸収する', () => {
      const r = verifyQuoteExistence(mockBody, '受付時間は平日9時~17時です。詳細は“FAQ”ページをご覧ください');
      expect(r.isValid).toBe(true);
    });
  });

  describe('破棄すべきケース', () => {
    it('数値を改変した引用は NOT_FOUND', () => {
      const r = verifyQuoteExistence(mockBody, '加盟店の決済手数料は従来の3.2%から一律無料へと引き下げられます。');
      expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
    });

    it('語尾を断定調に改変した引用は NOT_FOUND', () => {
      const r = verifyQuoteExistence(mockBody, '既存の加盟店様は特別な移行手続きが一切不要である。');
      expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
    });

    it('離れた2文を勝手に接合した引用は NOT_FOUND', () => {
      const r = verifyQuoteExistence(mockBody, '「PayNext」の提供を開始いたします。なお、既存の加盟店様におかれましては');
      expect(r).toEqual({ isValid: false, reason: 'NOT_FOUND' });
    });

    it('連続していても許容文数を超えて跨ぐ引用は SPAN_TOO_LONG', () => {
      const body = '一つ目の文です。二つ目の文です。三つ目の文です。四つ目の文です。';
      expect(verifyQuoteExistence(body, body)).toEqual({ isValid: false, reason: 'SPAN_TOO_LONG' });
      expect(verifyQuoteExistence(body, body, { maxSentences: 4 }).isValid).toBe(true);
    });

    it('短すぎる引用は TOO_SHORT、空白のみは EMPTY', () => {
      expect(verifyQuoteExistence(mockBody, '当社は')).toEqual({ isValid: false, reason: 'TOO_SHORT' });
      expect(verifyQuoteExistence(mockBody, ' \n ')).toEqual({ isValid: false, reason: 'EMPTY' });
    });
  });

  describe('空白の扱い', () => {
    it('日本語に隣接する空白だけを除去し、英単語間の空白は保持する', () => {
      const body = 'The fee will be reduced to 1.5% for all merchants.';
      expect(verifyQuoteExistence(body, 'fee will be reduced to 1.5%').isValid).toBe(true);
      expect(verifyQuoteExistence(body, 'feewill be reduced to 1.5%')).toEqual({ isValid: false, reason: 'NOT_FOUND' });
      expect(verifyQuoteExistence('PayNextの提供を開始します。', 'PayNext の提供を開始します').isValid).toBe(true);
    });
  });
});

describe('filterVerifiedEvidence', () => {
  it('合格と破棄を振り分け、破棄理由を返す', () => {
    const base = { sourceUrl: 'https://example.com/news', summary: '' };
    const { verified, discarded } = filterVerifiedEvidence(mockBody, [
      { ...base, quote: '特別な移行手続きは一切不要です。' },
      { ...base, quote: '手数料は来年さらに0.5%まで下がる予定です。' },
    ]);
    expect(verified).toHaveLength(1);
    expect(discarded.map((d) => d.reason)).toEqual(['NOT_FOUND']);
  });
});
 ✓ verifyQuote.test.ts (11 tests)
      Tests  11 passed (11)

導入時の調整ポイント

パラメータ 既定値 調整の目安
minLength 8 短い引用は本文のどこかに偶然含まれやすく、根拠として意味を持ちません。英語中心なら 20 前後に上げてください。
maxSentences 3 プロンプトで「1〜3文で引用せよ」と指示している数と揃えます。引用の前後が文の途中で切れると窓が1つ多く必要になるので、余裕を持たせたい場合は 4 に。

文分割と窓の扱いについては、次の3点に注意してください。

  • 英語の略語が多い文書:Dr. U.S. Dept. などのピリオドで文が細かく分割されるため、1文の正しい引用が SPAN_TOO_LONG になることがあります。英語中心なら maxSentences を大きめにするか、欧文側の境界に「略語+ピリオド」を除外する否定後読みを足してください(なお Intl.Segmenter の文分割でも、この例は略語の位置で分割されました)。

    // 「Dr.」「U.S.」「J.」(イニシャル)などの直後では文を区切らない
    const SENTENCE_BOUNDARY =
      /(?<!\b(?:Dr|Mr|Ms|Dept|U\.S|[A-Z])\.)(?<=[.!?])\s+|(?<=[。!?])/g;
    
  • 句点のない箇条書き:全体が1つの「文」として扱われるため、局所性判定が効きません(存在判定は正常に機能します)。箇条書きが多いソースでは、文分割に改行も加える選択肢があります。ただし和文では文中改行と区別できなくなるので、取得元の性質に合わせて判断してください。

  • 巨大な本文:窓をすべて事前生成するため、メモリは本文の約 maxSentences 倍を使い、引用1件ごとに全窓を走査します。Webページ程度なら問題になりませんが、MB級の文書を扱う場合は、窓を事前生成せず、存在判定を通った引用についてだけ窓を逐次生成し、ヒットした時点で打ち切る形に変えるとメモリを抑えられます。

また、本文の取得元に合わせて正規化を拡張するのも有効です(例:PDF 由来ならハイフネーションの除去、和文の鉤括弧 「」 と "" の統一など)。その際も**「意味が変わらない差だけを吸収する」**という原則は崩さないようにしてください。


まとめ

  • LLM の「引用」はテキスト抽出ではなく、確率的な作文である。
  • プロンプトの制約(「一言一句変えるな」)は防壁にならない。
  • 正規化で吸収するのは表記ゆれだけ。内容の改変は落とす。
  • 照合は「存在判定(本文全体)」と「局所性判定(スライディングウィンドウ)」の2段で行う。
  • 照合が保証するのは「引用の実在」まで。要約との整合は別の層で扱う。
  • 落ちた根拠はリトライせず即時破棄し、破棄率を監視する。

意味の解釈や要約は AI に任せ、事実の確認と合否の決定はコードが握る。
この境界線を引くことが、本番で信頼できる LLM アプリケーションの土台になります。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?