0
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?

TypeScriptでストリーミング文字起こしのPIIを伏字にする:区間の重なりとrevisionを安全に扱う

0
Posted at

この記事は、AI面接が録画・分析する仕組みを背景資料として、ストリーミング文字起こしの表示層を扱う技術記事として新たに書き直したものです。

ASR revisionを受け取り、確定した最新版だけを検出、区間選択、伏字表示へ進める処理フロー

リアルタイム文字起こしでは、発話の途中結果が何度も届き、同じ区間が revision 付きで書き換えられます。このとき表示用のテキストにメールアドレスや電話番号が混ざるなら、単に replace() を呼ぶだけでは不十分です。

この記事では、表示直前に PII を伏字にする最小実装を TypeScript で組みます。扱う論点は次の 3 つです。

  • 複数の検出規則が重なったとき、置換範囲を壊さない
  • 古い revision が新しい結果を上書きしない
  • 未確定の途中結果は表示しない

ここで作るものは汎用的な個人情報検出器ではありません。検出対象、保存先、ログの扱いはプロダクトのデータ分類と運用要件に合わせて別途設計してください。狙いは、表示層で誤って生テキストを露出しないための、検証しやすい境界を置くことです。

先に設計を決める

ASR のイベントを次の形で受け取るとします。

export type Segment = {
  id: string;
  revision: number;
  isFinal: boolean;
  text: string;
};

今回の表示ポリシーは単純です。

  1. segment ごとに最大の revision だけを採用する
  2. isFinal: false の途中結果は画面に渡さない
  3. 確定したテキストに対してだけ脱敏を行う

途中結果を出す UI も成立します。ただし分割位置の違いでメールアドレスや電話番号が二つのイベントにまたがるため、別途のバッファリング設計が必要です。まずは「確定した最新版のみ」を処理境界にすると、脱敏の前提を明確にできます。

正規表現の結果は文字列ではなく区間として集める

検出規則が一つだけなら text.replace() で足ります。しかし規則が増えると、同じ文字列の一部を複数の規則が検出します。たとえば電話番号全体と、末尾 8 桁を拾う簡易規則は重なります。

電話番号全体の区間と末尾区間が重複し、より長い電話番号区間を選ぶ図

そこで各検出結果を start / end を持つ区間として集めます。そのあと開始位置の昇順、同じ開始位置なら長い区間を先にして、すでに選んだ区間に重なるものを捨てます。

type Detector = {
  label: string;
  pattern: RegExp;
  replacement: string;
};

type Match = {
  start: number;
  end: number;
  replacement: string;
};

const DETECTORS: readonly Detector[] = [
  {
    label: "email",
    pattern: /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi,
    replacement: "[email redacted]",
  },
  {
    label: "phone",
    pattern: /(?:\+81[- ]?)?0\d{1,4}[- ]?\d{1,4}[- ]?\d{3,4}/g,
    replacement: "[phone redacted]",
  },
  {
    label: "phone-tail",
    pattern: /\d{4}[- ]?\d{4}/g,
    replacement: "[number redacted]",
  },
];

function collectMatches(text: string): Match[] {
  return DETECTORS.flatMap(({ pattern, replacement }) => {
    pattern.lastIndex = 0;
    return [...text.matchAll(pattern)].map((match) => ({
      start: match.index,
      end: match.index + match[0].length,
      replacement,
    }));
  });
}

function selectNonOverlapping(matches: readonly Match[]): Match[] {
  const sorted = [...matches].sort(
    (a, b) => a.start - b.start || b.end - a.end,
  );
  const selected: Match[] = [];
  let coveredUntil = 0;

  for (const match of sorted) {
    if (match.start >= coveredUntil) {
      selected.push(match);
      coveredUntil = match.end;
    }
  }

  return selected;
}

RegExpg を付けて再利用する場合、lastIndex は状態を持ちます。毎回 0 に戻さないと、前回の検索位置から始まり、同じ入力でも検出結果が変わります。小さな実装ですが、ストリーミング処理では再現性に直結します。

選んだ区間を一度だけ置換する

選択済みの区間は重ならないため、左から一回走査すれば元の文字列を壊さずに組み立てられます。

export function redact(text: string): string {
  const matches = selectNonOverlapping(collectMatches(text));
  let result = "";
  let cursor = 0;

  for (const match of matches) {
    result += text.slice(cursor, match.start) + match.replacement;
    cursor = match.end;
  }

  return result + text.slice(cursor);
}

この方法では、先に短い規則で置換したせいで後続の index がずれる問題もありません。検出規則を追加する場合も、置換処理を増やすのではなく DETECTORS に追加できます。

revision を持つ segment に接続する

最後に、segment ごとの最新 revision を記録します。確定した最新版だけを返すようにすると、呼び出し側は undefined を描画せず、返ったテキストだけを画面に追加できます。

export class FinalSegmentRedactor {
  #latestRevision = new Map<string, number>();

  upsert(segment: Segment): { id: string; text: string } | undefined {
    const latest = this.#latestRevision.get(segment.id) ?? -1;
    if (segment.revision <= latest) return undefined;

    this.#latestRevision.set(segment.id, segment.revision);
    if (!segment.isFinal) return undefined;

    return { id: segment.id, text: redact(segment.text) };
  }
}

重要なのは、脱敏後の文字列を次の revision の入力に使わないことです。入力は常に最新の生テキストから判定し、表示に渡す段階でだけ redact() を適用します。そうしないと、途中の伏字が次の revision に混ざり、元の認識結果との対応が失われます。

最低限の境界テスト

実装では通常ケースよりも、重なりと順序を先にテストしておくと安心です。Bun のテストでは次の 3 ケースを確認しました。

import { expect, test } from "bun:test";
import { FinalSegmentRedactor, redact } from "./stream-redactor";

test("email and telephone number are redacted", () => {
  expect(redact("連絡先は dev@example.jp、050-1234-5678 です。")).toBe(
    "連絡先は [email redacted]、[phone redacted] です。",
  );
});

test("the longer phone interval wins over its overlapping tail", () => {
  expect(redact("050-1234-5678")).toBe("[phone redacted]");
});

test("only the newest final revision can be displayed", () => {
  const redactor = new FinalSegmentRedactor();

  expect(redactor.upsert({ id: "s1", revision: 1, isFinal: false, text: "途中" })).toBeUndefined();
  expect(redactor.upsert({ id: "s1", revision: 2, isFinal: true, text: "dev@example.jp" })).toEqual({
    id: "s1",
    text: "[email redacted]",
  });
  expect(redactor.upsert({ id: "s1", revision: 1, isFinal: true, text: "古い" })).toBeUndefined();
});

実行結果は 3 tests / 5 assertions がすべて成功でした。

bun test stream-redactor.test.ts

実運用へ広げるときの注意

この実装を本番に持ち込む前に、次を確認します。

  • 電話番号や住所、社員番号など、扱うデータに対応した検出器を定義する
  • 表示だけでなく、アプリケーションログや分析イベントに生テキストを出していないか確認する
  • データを保存する場合は、保存前の脱敏、アクセス制御、削除期限を別の責務として設計する
  • 途中結果も表示するなら、segment 境界をまたぐ検出のために短い末尾バッファを持つ

文字起こしの品質改善と、表示してよい情報の制御は別問題です。revision の採用、区間の正規化、表示前の脱敏を分けておくと、検出規則や UI の方針が変わっても、それぞれを独立してテストできます。

0
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
0
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?