AWS Comprehendで英語をシンタックスハイライトしてみた

AWS Comprehendを使い英文の品詞を判別しシンタックスハイライトしてみました。
2020.11.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、加藤です。皆さんは技術書は物理 or 電子どちらで読みますか?私はもっぱら電子書籍です。技術書、とくにプログラミングに関する本を電子書籍で読んでいると困ることが1つあります、それはシンタックスハイライトが無いことです。シンタックスハイライトが無いと格段にコードが読みにくくなってしまいます?

話は変わって、最近わたしはずっと英語の勉強に注力していました。しかし、なかなか成果は出ておらずとくに長い英文になると超低速でも読めれば良い方で、読めないことも多々ありました。

ふと、自然言語でもシンタックスハイライトしたらもしかして読みやすくなるのではと思いたち、Comprehendを使ってデモアプリを作ってみました。

前提

本ブログでは作成したアプリケーションの要所々々だけを説明します、このブログだけを読んでも同様にアプリケーションを作成する事はできません。リポジトリを公開しているので、詳細が気になる場合はご確認ください。

https://github.com/intercept6/natural-language-syntax-highlight

アーキテクチャ

お試しなので特に検討せず私が慣れている技術スタックで構築します。

全体構成はSPA & REST APIとし、バックエンドはAPI GatewayのHTTP APIとLambdaを、フロントエンドはReactを使ってAmplify Consoleにホスティングします。

なお、すべての構成はAWS CDKを使って構築しました。

フロントエンドのCI/CDはAmplify Consoleで行っていますが、バックエンドのCI/CDはサボったので手元で行います。

フロントエンド、バックエンドともにTypeScriptなので、YarnのWorkspace機能を使って1つのリポジトリで管理しています。

バックエンド

バックエンドのコンピュートには1つのLambda関数だけで構築されています。

import { Comprehend } from 'aws-sdk';
import type {
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2,
} from 'aws-lambda';
import { badRequest, internalServerError } from './response';
import { getEnvironmentVariable } from './utils/getEnvironmentVariable';

type SyntaxHighlightedText = {
  id: number;
  text: string;
  tag: string;
};

const region = getEnvironmentVariable('AWS_REGION');
const lowerLimitScore = Number(getEnvironmentVariable('LOWER_LIMIT_SCORE'));
if (isNaN(lowerLimitScore)) {
  throw new Error(
    `environment variable LOWER_LIMIT_SCORE cannot be converted number type`
  );
}

const comprehend = new Comprehend({ region });

const getTag = ({
  tag,
  score,
}: {
  readonly tag?: string;
  readonly score?: number;
}) => {
  if (tag == null || score == null) {
    return 'UNKNOWN';
  }
  if (lowerLimitScore < 0.5) {
    return 'UNKNOWN';
  }
  return tag;
};

const getSyntaxTokenList = async (text: string) => {
  const res = await comprehend
    .detectSyntax({
      LanguageCode: 'en',
      Text: text,
    })
    .promise()
    .catch((err: Error) => err);

  if (res instanceof Error) {
    return res;
  }

  if (res.SyntaxTokens == null) {
    return new Error('response of AWS Comprehend is null');
  }

  return res.SyntaxTokens;
};

const getText = (event: APIGatewayProxyEventV2) => {
  if (event.queryStringParameters == null) {
    return new Error('query string is null');
  }

  if (event.queryStringParameters.text == null) {
    return new Error('query string text is null');
  }

  return event.queryStringParameters.text;
};

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  console.log('event', event);

  const text = getText(event);
  if (text instanceof Error) {
    console.error(text);
    return badRequest(text.message);
  }

  const syntaxTokenList = await getSyntaxTokenList(text);
  if (syntaxTokenList instanceof Error) {
    console.error(syntaxTokenList);
    return internalServerError();
  }

  const syntaxHighlightedTexts = syntaxTokenList.reduce(
    (
      accumulator: SyntaxHighlightedText[],
      { TokenId: id, Text: text, PartOfSpeech: partOfSpeech }
    ) => {
      if (id != null && text != null && partOfSpeech != null) {
        const currentValue: SyntaxHighlightedText = {
          id,
          text,
          tag: getTag({ tag: partOfSpeech.Tag, score: partOfSpeech.Score }),
        };
        accumulator.push(currentValue);
      }
      return accumulator;
    },
    []
  );

  return {
    statusCode: 200,
    body: JSON.stringify(syntaxHighlightedTexts),
  };
};

API Gatewayからのリクエストを受け取るとhandler関数が実行されます。下記の順番で処理を行っています。

  1. リクエストのクエリ文字列からテキストを取得する
  2. Comprehendを使ってテキストを分割し品詞を判断する
  3. 2の出力をフロントエンドで処理しやすいように変更する
export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  console.log('event', event);

  // 1. リクエストのクエリ文字列からテキストを取得する
  const text = getText(event);
  if (text instanceof Error) {
    console.error(text);
    return badRequest(text.message);
  }

  // 2. Comprehendを使ってテキストを分割し品詞を判断する
  const syntaxTokenList = await getSyntaxTokenList(text);
  if (syntaxTokenList instanceof Error) {
    console.error(syntaxTokenList);
    return internalServerError();
  }

  // 3. 2の出力をフロントエンドで処理しやすいように変更する
  const syntaxHighlightedTexts = syntaxTokenList.reduce(
    (
      accumulator: SyntaxHighlightedText[],
      { TokenId: id, Text: text, PartOfSpeech: partOfSpeech }
    ) => {
      if (id != null && text != null && partOfSpeech != null) {
        const currentValue: SyntaxHighlightedText = {
          id,
          text,
          tag: getTag({ tag: partOfSpeech.Tag, score: partOfSpeech.Score }),
        };
        accumulator.push(currentValue);
      }
      return accumulator;
    },
    []
  );

  return {
    statusCode: 200,
    body: JSON.stringify(syntaxHighlightedTexts),
  };
};

Comprehendを使って品詞を識別(構文解析)するには detectSyntax を使用します。英語以外に対応するつもりがないので、ハードコードしていますが、英語、スペイン語、フランス語、ドイツ語、イタリア語、ポルトガル語に対応しています。クエリ文字列で言語を指定可能にしたり、Comprehendの言語検出機能を合わせて使用することで自動検出させてるとより汎用的なアプリケーションになりますね。

const getSyntaxTokenList = async (text: string) => {
  const res = await comprehend
    .detectSyntax({
      LanguageCode: 'en',
      Text: text,
    })
    .promise()
    .catch((err: Error) => err);

  if (res instanceof Error) {
    return res;
  }

  if (res.SyntaxTokens == null) {
    return new Error('response of AWS Comprehend is null');
  }

  return res.SyntaxTokens;
};

構文解析された結果には、品質とともにスコアが付属しています。これは判定結果がどれだけ信用できるかという指標です。指定したスコア未満の場合はUNKNOWNとして扱うことにしました、しきい値は環境変数LOWER_LIMIT_SCOREで行います。

const getTag = ({
  tag,
  score,
}: {
  readonly tag?: string;
  readonly score?: number;
}) => {
  if (tag == null || score == null) {
    return 'UNKNOWN';
  }
  if (score < lowerLimitScore) {
    return 'UNKNOWN';
  }
  return tag;
};

フロントエンド

めっちゃ汚いです、ごめんなさい許してください。

品詞の型を定義し、品詞名をキーとしてカラーコードや説明を持つオブジェクトを作成し、これにマッチングさせることで色の設定や、凡例の表示をおこなっています。

あとは、Material UIを使ってインプットのためのTextFieldを用意しアウトプットはPaperに表示しています。

今までAPIアクセスにはaxiosしか使ったことがなかったのですが、なんとなく初めてfetchを使ってみました。

import * as React from 'react';
import {
  Button,
  createStyles,
  Grid,
  makeStyles,
  Paper,
  TextField,
  Theme,
} from '@material-ui/core';

type Tag =
  | 'ADJ'
  | 'ADP'
  | 'ADV'
  | 'AUX'
  | 'CONJ'
  | 'CCONJ'
  | 'DET'
  | 'INTJ'
  | 'NOUN'
  | 'NUM'
  | 'Other'
  | 'PART'
  | 'PRON'
  | 'PROPN'
  | 'PUNCT'
  | 'SCONJ'
  | 'SYM'
  | 'VERB'
  | 'UNKNOWN';

type OutputData = {
  id: number;
  text: string;
  tag: Tag;
};

type Colors = {
  [key in Tag]: {
    colorForDark: string;
    description: string;
    descriptionJP: string;
  };
};

const colors: Colors = {
  NOUN: {
    colorForDark: '#f99157',
    description: 'Noun',
    descriptionJP: '名詞',
  },
  PRON: {
    colorForDark: '#ffd5be',
    description: 'Pronoun',
    descriptionJP: '代名詞',
  },
  PROPN: {
    colorForDark: '#ff6f22',
    description: 'Proper noun',
    descriptionJP: '固有名詞',
  },
  VERB: {
    colorForDark: '#cc99cc',
    description: 'Verb',
    descriptionJP: '動詞',
  },
  AUX: {
    colorForDark: '#b05657',
    description: 'Auxiliary',
    descriptionJP: '助動詞',
  },
  ADP: {
    colorForDark: '#ffcc66',
    description: 'Adposition',
    descriptionJP: '接置詞',
  },
  DET: {
    colorForDark: '#99cc99',
    description: 'Determiner',
    descriptionJP: '限定詞',
  },
  ADV: {
    colorForDark: '#f2777a',
    description: 'Adverb',
    descriptionJP: '副詞',
  },
  ADJ: {
    colorForDark: '#6699cc',
    description: 'Adjective',
    descriptionJP: '形容詞',
  },
  CONJ: {
    colorForDark: '#d27b53',
    description: 'Conjunction',
    descriptionJP: '接続詞',
  },
  CCONJ: {
    colorForDark: '#d27b53',
    description: 'Coordinating conjunction',
    descriptionJP: '等位接続詞',
  },
  SCONJ: {
    colorForDark: '#d27b53',
    description: 'Subordinating conjunction',
    descriptionJP: '従属接続詞',
  },
  INTJ: {
    colorForDark: '#66cccc',
    description: 'Interjection',
    descriptionJP: '間投詞',
  },
  PART: {
    colorForDark: '#6699cc',
    description: 'Particle',
    descriptionJP: '不変化詞、小詞、接頭辞',
  },
  PUNCT: {
    colorForDark: '#f2f0ec',
    description: 'Punctuation',
    descriptionJP: '句読点',
  },
  NUM: {
    colorForDark: '#ffffff',
    description: 'Number',
    descriptionJP: '数値',
  },
  Other: {
    colorForDark: '#ffffff',
    description: 'Other',
    descriptionJP: 'その他',
  },
  SYM: {
    colorForDark: '#ffffff',
    description: 'Symbol',
    descriptionJP: '記号',
  },
  UNKNOWN: {
    colorForDark: '#ffffff',
    description: 'Unknown',
    descriptionJP: '不明',
  },
};

const getColorForDark = (tag: Tag) => colors[tag].colorForDark;

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      flexGrow: 1,
    },
    input: {
      padding: theme.spacing(2),
    },
    output: {
      padding: theme.spacing(2),
      height: '100%',
      backgroundColor: '#2d2d2d',
      fontSize: 'large',
    },
  })
);
export const SyntaxHighLight = () => {
  const [inputText, setInputText] = React.useState<string>('');
  const [outputText, setOutputText] = React.useState<React.ReactNode>(<span />);

  const handleChange = React.useCallback(
    (event: React.ChangeEvent<HTMLInputElement>) => {
      setInputText(event.target.value);
    },
    [setInputText]
  );

  const handleSubmit = React.useCallback(
    async (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();
      const qs = new URLSearchParams({
        text: inputText,
      });
      const res = await fetch(
        `${process.env.REACT_APP_API_URL}/syntax-highlighted-text?${qs}`,
        {
          method: 'GET',
          mode: 'cors',
          headers: {
            'Content-Type': 'application/json',
          },
        }
      );
      if (!res.ok) {
        console.error(res);
        return;
      }
      const json = await res.json();
      setOutputText(
        json.map((value: OutputData) => (
          <span
            key={value.id}
            className={value.tag}
            style={{ color: getColorForDark(value.tag) }}
          >
            {value.text}{' '}
          </span>
        ))
      );
    },
    [inputText]
  );

  const classes = useStyles();

  return (
    <>
      <div className={classes.root}>
        <h1>自然言語シンタックスハイライト(英語)</h1>
        <form onSubmit={handleSubmit} noValidate autoComplete="off">
          <Grid container spacing={2}>
            <Grid item xs={6}>
              <Paper className={classes.input}>
                <TextField
                  name="input"
                  label="インプット"
                  multiline
                  rows={10}
                  variant="outlined"
                  fullWidth
                  value={inputText}
                  onChange={handleChange}
                />
              </Paper>
            </Grid>
            <Grid item xs={6}>
              <Paper className={classes.output}>{outputText}</Paper>
            </Grid>
            <Grid item xs={12}>
              <Paper className={classes.output}>
                {Object.entries(colors).map(([key, value]) => (
                  <span key={key} style={{ color: value.colorForDark }}>
                    {value.description} : {value.descriptionJP}
                    <br />
                  </span>
                ))}
              </Paper>
            </Grid>
            <Grid item xs={12}>
              <Button variant="contained" color="primary" type="submit">
                実行する
              </Button>
            </Grid>
          </Grid>
        </form>
      </div>
    </>
  );
};

AWS

AWSのリソースをCDKで作成しました。CDKはLambda関数を作るときに自動でIAM Roleを生成してくれるのですが、今回のようにComprehendを利用することをCDKは知りようが無いので明示的にRoleを作成して渡す必要があります。AWSが事前定義しているポリシーを使ってRoleを作成したのですが、AWSLambdaBasicExecutionRoleを指定する場合は、service-role/と頭に付ける必要があることを知らず少しハマりました。

import { Construct, SecretValue, Stack, StackProps } from '@aws-cdk/core';
import {
  HttpApi,
  HttpMethod,
  LambdaProxyIntegration,
} from '@aws-cdk/aws-apigatewayv2';
import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs';
import { ManagedPolicy, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
import { App, GitHubSourceCodeProvider } from '@aws-cdk/aws-amplify';
import { BuildSpec } from '@aws-cdk/aws-codebuild';

export class MainStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const role = new Role(this, 'IndexRole', {
      assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSLambdaBasicExecutionRole'
        ),
        ManagedPolicy.fromAwsManagedPolicyName('ComprehendFullAccess'),
      ],
    });
    const indexFn = new NodejsFunction(this, 'IndexFunction', {
      entry: '../backend/src/index.ts',
      role,
      environment: {
        LOWER_LIMIT_SCORE: '0.5',
      },
    });

    const httpApi = new HttpApi(this, 'HttpApi', {
      corsPreflight: {
        allowOrigins: ['*'],
        allowMethods: [HttpMethod.GET, HttpMethod.OPTIONS],
        allowHeaders: ['*'],
      },
    });
    httpApi.addRoutes({
      path: '/syntax-highlighted-text',
      methods: [HttpMethod.GET],
      integration: new LambdaProxyIntegration({ handler: indexFn }),
    });

    const amplifyApp = new App(this, 'AmplifyApp', {
      appName: 'NSH',
      environmentVariables: {
        REACT_APP_API_URL: httpApi.url!,
      },
      sourceCodeProvider: new GitHubSourceCodeProvider({
        owner: 'intercept6',
        repository: 'natural-language-syntax-highlight',
        oauthToken: SecretValue.secretsManager(
          'github-token-for-amplify-console'
        ),
      }),
      buildSpec: BuildSpec.fromObject({
        version: '1.0',
        applications: [
          {
            frontend: {
              phases: {
                preBuild: {
                  commands: ['nvm use $VERSION_NODE_12', 'yarn install'],
                },
                build: {
                  commands: ['yarn run build'],
                },
              },
              artifacts: {
                baseDirectory: 'build',
                files: ['**/*'],
              },
              cache: {
                paths: ['node_modules/**/*'],
              },
            },
            appRoot: 'packages/frontend',
          },
        ],
      }),
    });

    amplifyApp.addBranch('master', {
      branchName: 'master',
    });
  }
}

あとがき

シンタックスハイライトさせてみたが、あまりわかりやすくならなかったです。。。真面目にやるならもっと配色にこだわる必要がありそう。そして、面倒でいちいちコピペなんてしてられないので、実用的にするにはブラウザの拡張機能として作る必要がありますね。これは作る前から想像できていたので、フロントエンドの部分は動きさえすれば良いというノリで適当に作ってしまいました。機械学習の知識がゼロでもこういうことができてしまうのでマネージドサービスは本当に便利ですね。

また、お試しとして書いた事と関係なく全体的にコードが醜いのでもってキレイに書けるようになりたい。

以上でした。