系列: HarmonyOS 智能工具箱 App 全栈开发 · 第 6 篇

上一篇: 端侧模型推理实战


引言

前五篇我们完成了 OCR 识别、语音交互、图像处理和模型推理。本篇将聚焦自然语言处理(NLP)——在设备端对中文文本进行分词、命名实体识别(NER)和关键词提取。

Natural Language Kit 提供端侧 NLP 能力,所有处理在设备本地完成,无需网络连接,适合对隐私敏感的文本分析场景。结合第三篇的 TTS 语音朗读,我们还能让分析结果"开口说话"。

通过本文你将学到:

  • 使用 Natural Language Kit 实现中文分词、NER 和关键词提取
  • 封装 NlpService 统一管理 NLP 任务
  • 构建关键词云和实体高亮的可视化 UI
  • 集成 TTS 朗读分析结果

环境准备

项目 版本
DevEco Studio 6.1.1 Release (6.1.1.280)
API Level API 24 (HarmonyOS 6.1.1 Release)
设备 真机 / 模拟器

依赖配置

// oh-package.json5
{
  "dependencies": {
    "@kit.NaturalLanguageKit": "^1.0.0",
    "@kit.CoreSpeechKit": "^1.0.0",
    "@kit.PerformanceAnalysisKit": "^1.0.0"
  }
}

Natural Language Kit 无需额外权限,所有处理在端侧完成。TTS 功能复用第三篇的 SpeechService。


核心实现

Step 1: 封装 NLP 服务层

目标: 统一封装分词、NER、关键词提取三大能力,提供简洁的服务接口。

// service/NlpService.ets
import { nlu, WordSegmentationResult, NERResult, KeywordExtractionResult } from '@kit.NaturalLanguageKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'NlpService';
const DOMAIN = 0xFF00;

// 分词结果项
export class WordItem {
  word: string = '';
  pos: string = '';
}

// 实体识别结果项
export class EntityItem {
  text: string = '';
  type: string = '';
  offset: number = 0;
}

// 关键词结果项
export class KeywordItem {
  keyword: string = '';
  score: number = 0;
}

// 综合分析结果
export class NlpAnalysisResult {
  words: WordItem[] = [];
  entities: EntityItem[] = [];
  keywords: KeywordItem[] = [];
  textLength: number = 0;
  analyzeTime: number = 0;
}

// 实体类型枚举
export enum EntityType {
  PERSON = 'PER',
  LOCATION = 'LOC',
  ORGANIZATION = 'ORG'
}

// 实体类型中文映射
export const ENTITY_TYPE_MAP: Record<string, string> = {
  'PER': '人名',
  'LOC': '地名',
  'ORG': '机构'
};

// 词性中文映射
export const POS_MAP: Record<string, string> = {
  'n': '名词',
  'v': '动词',
  'a': '形容词',
  'd': '副词',
  'r': '代词',
  'm': '数词',
  'q': '量词',
  'p': '介词',
  'c': '连词',
  'u': '助词',
  'e': '叹词',
  'y': '语气词',
  'o': '拟声词',
  'x': '非语素字',
  'w': '标点'
};

export class NlpService {
  /**
   * 中文分词
   */
  static async segmentWords(text: string): Promise<WordItem[]> {
    try {
      const result: WordSegmentationResult = await nlu.wordSegmentation(text);
      const words: WordItem[] = result.words.map((w) => {
        const item = new WordItem();
        item.word = w.word;
        item.pos = w.pos;
        return item;
      });
      hilog.info(DOMAIN, TAG, `分词完成: ${words.length} 个词`);
      return words;
    } catch (err) {
      hilog.error(DOMAIN, TAG, `分词失败: ${JSON.stringify(err)}`);
      return [];
    }
  }

  /**
   * 命名实体识别
   */
  static async recognizeEntities(text: string): Promise<EntityItem[]> {
    try {
      const result: NERResult = await nlu.namedEntityRecognition(text);
      const entities: EntityItem[] = result.entities.map((e) => {
        const item = new EntityItem();
        item.text = e.text;
        item.type = e.type;
        item.offset = e.offset;
        return item;
      });
      hilog.info(DOMAIN, TAG, `实体识别完成: ${entities.length} 个实体`);
      return entities;
    } catch (err) {
      hilog.error(DOMAIN, TAG, `实体识别失败: ${JSON.stringify(err)}`);
      return [];
    }
  }

  /**
   * 关键词提取
   */
  static async extractKeywords(text: string, topK: number = 10): Promise<KeywordItem[]> {
    try {
      const result: KeywordExtractionResult = await nlu.keywordExtraction(text, topK);
      const keywords: KeywordItem[] = result.keywords.map((k) => {
        const item = new KeywordItem();
        item.keyword = k.keyword;
        item.score = k.score;
        return item;
      });
      hilog.info(DOMAIN, TAG, `关键词提取完成: ${keywords.length} 个关键词`);
      return keywords;
    } catch (err) {
      hilog.error(DOMAIN, TAG, `关键词提取失败: ${JSON.stringify(err)}`);
      return [];
    }
  }

  /**
   * 综合文本分析:并行执行分词、NER、关键词提取
   */
  static async analyzeText(text: string, topK: number = 10): Promise<NlpAnalysisResult> {
    const startTime = Date.now();

    const [words, entities, keywords] = await Promise.all([
      this.segmentWords(text),
      this.recognizeEntities(text),
      this.extractKeywords(text, topK)
    ]);

    const result = new NlpAnalysisResult();
    result.words = words;
    result.entities = entities;
    result.keywords = keywords;
    result.textLength = text.length;
    result.analyzeTime = Date.now() - startTime;

    hilog.info(DOMAIN, TAG,
      `综合分析完成: ${result.analyzeTime}ms, ${words.length}词, ${entities.length}实体, ${keywords.length}关键词`);

    return result;
  }

  /**
   * 按实体类型分组
   */
  static groupEntitiesByType(entities: EntityItem[]): Map<string, EntityItem[]> {
    const groups = new Map<string, EntityItem[]>();

    for (const entity of entities) {
      const list = groups.get(entity.type) ?? [];
      list.push(entity);
      groups.set(entity.type, list);
    }

    return groups;
  }

  /**
   * 生成文本朗读摘要
   */
  static generateSpeechText(result: NlpAnalysisResult): string {
    const parts: string[] = [];

    parts.push(`文本共 ${result.textLength} 个字符,分析耗时 ${result.analyzeTime} 毫秒。`);

    if (result.keywords.length > 0) {
      const kwText = result.keywords.slice(0, 5).map((k) => k.keyword).join('、');
      parts.push(`关键词包括:${kwText}`);
    }

    if (result.entities.length > 0) {
      const groups = this.groupEntitiesByType(result.entities);
      groups.forEach((list, type) => {
        const typeName = ENTITY_TYPE_MAP[type] ?? type;
        const names = list.map((e) => e.text).join('、');
        parts.push(`${typeName}${names}`);
      });
    }

    return parts.join('');
  }
}

要点说明

  • 三个 NLP 接口通过 Promise.all 并行调用,互不依赖,显著提升效率
  • 综合分析结果包含分词、实体、关键词三类数据,方便 UI 层统一展示
  • generateSpeechText 方法将分析结果转为自然语言摘要,供 TTS 朗读使用

Step 2: 创建 NLP 分析页面

目标: 实现文本输入和分析结果展示的主页面,使用 @ObservedV2/@Trace 管理状态。

// features/nlp/NlpPage.ets
import { NlpService, NlpAnalysisResult, WordItem, EntityItem, KeywordItem, ENTITY_TYPE_MAP, POS_MAP } from '../../service/NlpService';
import { SpeechService } from '../../service/SpeechService';
import { KeywordCloud } from './KeywordCloud';
import { EntityHighlight } from './EntityHighlight';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'NlpPage';

// 分析状态管理(V2 状态管理)
@ObservedV2
class NlpPageState {
  @Trace inputText: string = '';
  @Trace isAnalyzing: boolean = false;
  @Trace result: NlpAnalysisResult | null = null;
  @Trace activeTab: number = 0;
  @Trace isSpeaking: boolean = false;
}

@Entry
@Component
struct NlpPage {
  private state: NlpPageState = new NlpPageState();
  private tabs: string[] = ['关键词', '实体', '分词'];
  private sampleTexts: string[] = [
    '任正非在深圳华为总部接受了媒体采访,表示鸿蒙操作系统将在万物互联时代发挥关键作用。',
    'HarmonyOS 是一款面向万物互联时代的智能终端操作系统,分布式能力是其核心特性。',
    '北京市海淀区的清华大学和北京大学是中国最著名的高等学府,每年吸引大量国内外学生。'
  ];

  build() {
    Column({ space: 12 }) {
      // 标题栏
      Row() {
        Text('自然语言分析')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        Blank()
        if (this.state.isSpeaking) {
          Button('停止朗读')
            .type(ButtonType.Normal)
            .height(32)
            .fontSize(12)
            .onClick(() => this.stopSpeaking())
        }
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      // 文本输入区
      Column({ space: 8 }) {
        TextArea({ text: this.state.inputText, placeholder: '请输入要分析的中文文本...' })
          .height(120)
          .fontSize(14)
          .onChange((value: string) => {
            this.state.inputText = value;
          })

        // 示例文本
        Row({ space: 8 }) {
          Text('示例:').fontSize(12).fontColor('#999')
          ForEach(this.sampleTexts, (_: string, index: number) => {
            Button(`示例${index + 1}`)
              .type(ButtonType.Normal)
              .height(24)
              .fontSize(11)
              .onClick(() => {
                this.state.inputText = this.sampleTexts[index];
              })
          })
        }
        .width('100%')
      }
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)

      // 操作按钮
      Row({ space: 8 }) {
        Button('开始分析')
          .type(ButtonType.Capsule)
          .layoutWeight(1)
          .height(40)
          .enabled(this.state.inputText.length > 0 && !this.state.isAnalyzing)
          .onClick(() => this.startAnalysis())

        Button('朗读结果')
          .type(ButtonType.Capsule)
          .layoutWeight(1)
          .height(40)
          .enabled(this.state.result !== null && !this.state.isSpeaking)
          .onClick(() => this.speakResult())
      }
      .width('100%')
      .padding({ left: 16, right: 16 })

      // 分析中提示
      if (this.state.isAnalyzing) {
        Row({ space: 8 }) {
          LoadingProgress().width(24).height(24)
          Text('正在分析中...').fontSize(13).fontColor('#666')
        }
        .padding(12)
      }

      // 分析结果
      if (this.state.result !== null) {
        this.buildResultSection()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  @Builder
  buildResultSection() {
    Column({ space: 12 }) {
      // 统计信息
      Row({ space: 16 }) {
        this.buildStatItem('字符数', `${this.state.result!.textLength}`)
        this.buildStatItem('分词数', `${this.state.result!.words.length}`)
        this.buildStatItem('实体数', `${this.state.result!.entities.length}`)
        this.buildStatItem('关键词', `${this.state.result!.keywords.length}`)
        this.buildStatItem('耗时', `${this.state.result!.analyzeTime}ms`)
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)

      // Tab 切换
      Row({ space: 0 }) {
        ForEach(this.tabs, (tab: string, index: number) => {
          Column() {
            Text(tab)
              .fontSize(14)
              .fontColor(this.state.activeTab === index ? '#007DFF' : '#666')
              .fontWeight(this.state.activeTab === index ? FontWeight.Bold : FontWeight.Normal)
            Divider()
              .strokeWidth(2)
              .color(this.state.activeTab === index ? '#007DFF' : 'transparent')
              .width('80%')
          }
          .layoutWeight(1)
          .height(40)
          .justifyContent(FlexAlign.Center)
          .onClick(() => {
            this.state.activeTab = index;
          })
        })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(8)

      // Tab 内容
      if (this.state.activeTab === 0) {
        this.buildKeywordsTab()
      } else if (this.state.activeTab === 1) {
        this.buildEntitiesTab()
      } else {
        this.buildWordsTab()
      }
    }
    .padding({ left: 16, right: 16 })
  }

  @Builder
  buildStatItem(label: string, value: string) {
    Column({ space: 4 }) {
      Text(value)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#007DFF')
      Text(label)
        .fontSize(11)
        .fontColor('#999')
    }
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  buildKeywordsTab() {
    Column({ space: 8 }) {
      Text('关键词提取结果')
        .fontSize(14)
        .fontColor('#666')
        .width('100%')
        .padding({ left: 4 })

      KeywordCloud({ keywords: this.state.result!.keywords })
    }
  }

  @Builder
  buildEntitiesTab() {
    Column({ space: 8 }) {
      Text('实体识别结果')
        .fontSize(14)
        .fontColor('#666')
        .width('100%')
        .padding({ left: 4 })

      EntityHighlight({
        originalText: this.state.inputText,
        entities: this.state.result!.entities
      })
    }
  }

  @Builder
  buildWordsTab() {
    Column({ space: 8 }) {
      Row() {
        Text('分词结果')
          .fontSize(14)
          .fontColor('#666')
        Blank()
        Text(`${this.state.result!.words.length} 个词`)
          .fontSize(12)
          .fontColor('#999')
      }
      .width('100%')
      .padding({ left: 4 })

      Flex({ wrap: FlexWrap.Wrap, space: { main: new LengthMetrics(8), cross: new LengthMetrics(6) } }) {
        ForEach(this.state.result!.words, (item: WordItem) => {
          Column({ space: 2 }) {
            Text(item.word)
              .fontSize(14)
              .fontColor('#333')
            Text(POS_MAP[item.pos] ?? item.pos)
              .fontSize(10)
              .fontColor('#999')
          }
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor('#F0F8FF')
          .borderRadius(4)
        })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
    }
  }

  async startAnalysis(): Promise<void> {
    if (this.state.inputText.length === 0) return;

    this.state.isAnalyzing = true;
    this.state.result = null;

    try {
      const result = await NlpService.analyzeText(this.state.inputText);
      this.state.result = result;
      this.state.activeTab = 0;
    } catch (err) {
      hilog.error(0xFF00, TAG, `分析失败: ${JSON.stringify(err)}`);
    } finally {
      this.state.isAnalyzing = false;
    }
  }

  async speakResult(): Promise<void> {
    if (!this.state.result) return;

    this.state.isSpeaking = true;
    const text = NlpService.generateSpeechText(this.state.result);

    try {
      await SpeechService.speak(text);
    } catch (err) {
      hilog.error(0xFF00, TAG, `朗读失败: ${JSON.stringify(err)}`);
    } finally {
      this.state.isSpeaking = false;
    }
  }

  stopSpeaking(): void {
    SpeechService.stop();
    this.state.isSpeaking = false;
  }
}

要点说明

  • 使用 @ObservedV2 / @Trace 实现 V2 状态管理,响应式更新 UI
  • 三个 Tab 分别展示关键词、实体、分词结果,通过 activeTab 切换
  • 示例文本按钮方便快速体验功能

Step 3: 创建关键词云组件

目标: 以标签云形式可视化展示关键词,字号随权重变化。

// features/nlp/KeywordCloud.ets
import { KeywordItem } from '../../service/NlpService';

// 关键词颜色池
const KEYWORD_COLORS: string[] = [
  '#007DFF', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
  '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE'
];

@Component
struct KeywordCloud {
  @Prop keywords: KeywordItem[] = [];

  build() {
    Column({ space: 8 }) {
      if (this.keywords.length === 0) {
        Text('暂无关键词')
          .fontSize(13)
          .fontColor('#999')
          .width('100%')
          .textAlign(TextAlign.Center)
          .padding(24)
      } else {
        Flex({ wrap: FlexWrap.Wrap, space: { main: new LengthMetrics(10), cross: new LengthMetrics(8) } }) {
          ForEach(this.keywords, (item: KeywordItem, index: number) => {
            this.buildKeywordTag(item, index)
          })
        }
        .width('100%')
        .padding(12)
      }

      // 权重分布条
      if (this.keywords.length > 0) {
        Column({ space: 4 }) {
          Text('权重分布')
            .fontSize(12)
            .fontColor('#999')
            .width('100%')

          ForEach(this.keywords.slice(0, 5), (item: KeywordItem, index: number) => {
            Row({ space: 8 }) {
              Text(item.keyword)
                .fontSize(11)
                .fontColor('#666')
                .width(60)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Stack({ alignContent: Alignment.Start }) {
                Row()
                  .width(`${this.getBarWidth(item.score)}%`)
                  .height(12)
                  .borderRadius(6)
                  .backgroundColor(KEYWORD_COLORS[index % KEYWORD_COLORS.length])
                Row()
                  .width('100%')
                  .height(12)
                  .borderRadius(6)
                  .backgroundColor('#F0F0F0')
              }
              .layoutWeight(1)
              .height(12)

              Text(item.score.toFixed(3))
                .fontSize(10)
                .fontColor('#999')
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
          })
        }
        .padding(12)
        .backgroundColor('#FFFFFF')
        .borderRadius(8)
      }
    }
  }

  @Builder
  buildKeywordTag(item: KeywordItem, index: number) {
    Column() {
      Text(item.keyword)
        .fontSize(this.getFontSize(item.score))
        .fontWeight(FontWeight.Medium)
        .fontColor(KEYWORD_COLORS[index % KEYWORD_COLORS.length])
    }
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .backgroundColor(this.getTagBg(index))
    .borderRadius(16)
  }

  getFontSize(score: number): number {
    // 根据权重映射字号:12-22
    const minSize = 12;
    const maxSize = 22;
    return Math.round(minSize + score * (maxSize - minSize));
  }

  getBarWidth(score: number): number {
    return Math.round(score * 100);
  }

  getTagBg(index: number): string {
    const bgColors: string[] = [
      '#EBF5FF', '#FFF0F0', '#E8FFF8', '#EBF8FF', '#F0FFF0',
      '#FFFBE6', '#F5F0FF', '#E8F8F5', '#FFF9E6', '#F0EBFF'
    ];
    return bgColors[index % bgColors.length];
  }
}

要点说明

  • 关键词字号根据权重动态计算,权重越高字号越大
  • 权重分布条直观展示各关键词的重要性对比
  • 颜色池循环使用,视觉效果丰富

Step 4: 创建实体识别结果组件

目标: 在原文中高亮显示识别出的实体,按类型标注不同颜色。

// features/nlp/EntityHighlight.ets
import { EntityItem, ENTITY_TYPE_MAP } from '../../service/NlpService';

// 实体类型颜色映射
const ENTITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
  'PER': { bg: '#FFF0F0', text: '#D32F2F', border: '#FFCDD2' },
  'LOC': { bg: '#E8F5E9', text: '#2E7D32', border: '#C8E6C9' },
  'ORG': { bg: '#E3F2FD', text: '#1565C0', border: '#BBDEFB' }
};

// 文本片段
interface TextSegment {
  text: string;
  isEntity: boolean;
  entityType: string;
}

@Component
struct EntityHighlight {
  @Prop originalText: string = '';
  @Prop entities: EntityItem[] = [];

  build() {
    Column({ space: 12 }) {
      // 实体统计
      this.buildEntityStats()

      // 高亮文本
      Column({ space: 8 }) {
        Text('文本高亮')
          .fontSize(14)
          .fontColor('#666')
          .width('100%')

        this.buildHighlightedText()
      }
      .padding(12)
      .backgroundColor('#FFFFFF')
      .borderRadius(8)

      // 实体列表
      this.buildEntityList()
    }
  }

  @Builder
  buildEntityStats() {
    Row({ space: 12 }) {
      ForEach(Object.keys(ENTITY_COLORS), (type: string) => {
        Row({ space: 4 }) {
          Circle({ width: 8, height: 8 })
            .fill(ENTITY_COLORS[type].text)
          Text(ENTITY_TYPE_MAP[type] ?? type)
            .fontSize(12)
            .fontColor('#666')
          Text(`${this.getEntitiesByType(type).length}`)
            .fontSize(12)
            .fontColor(ENTITY_COLORS[type].text)
            .fontWeight(FontWeight.Bold)
        }
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor(ENTITY_COLORS[type].bg)
        .borderRadius(12)
        .border({ width: 1, color: ENTITY_COLORS[type].border })
      })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
  }

  @Builder
  buildHighlightedText() {
    // 将文本拆分为普通片段和实体片段
    Flex({ wrap: FlexWrap.Wrap, space: { main: new LengthMetrics(0), cross: new LengthMetrics(0) } }) {
      ForEach(this.splitTextIntoSegments(), (segment: TextSegment) => {
        if (segment.isEntity) {
          Text(segment.text)
            .fontSize(14)
            .fontColor(ENTITY_COLORS[segment.entityType]?.text ?? '#333')
            .backgroundColor(ENTITY_COLORS[segment.entityType]?.bg ?? '#F0F0F0')
            .borderRadius(2)
            .padding({ left: 2, right: 2 })
            .decoration({ type: TextDecorationType.Underline, color: ENTITY_COLORS[segment.entityType]?.border ?? '#CCC' })
        } else {
          Text(segment.text)
            .fontSize(14)
            .fontColor('#333')
        }
      })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FAFAFA')
    .borderRadius(6)
  }

  @Builder
  buildEntityList() {
    Column({ space: 8 }) {
      Text('实体列表')
        .fontSize(14)
        .fontColor('#666')
        .width('100%')

      List({ space: 6 }) {
        ForEach(this.entities, (entity: EntityItem) => {
          ListItem() {
            Row({ space: 8 }) {
              // 类型标签
              Text(ENTITY_TYPE_MAP[entity.type] ?? entity.type)
                .fontSize(11)
                .fontColor(ENTITY_COLORS[entity.type]?.text ?? '#333')
                .backgroundColor(ENTITY_COLORS[entity.type]?.bg ?? '#F0F0F0')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(4)

              // 实体文本
              Text(entity.text)
                .fontSize(14)
                .fontColor('#333')
                .fontWeight(FontWeight.Medium)

              Blank()

              // 位置信息
              Text(`位置: ${entity.offset}`)
                .fontSize(11)
                .fontColor('#999')
            }
            .width('100%')
            .padding(10)
            .backgroundColor('#FFFFFF')
            .borderRadius(6)
          }
        })
      }
      .width('100%')
    }
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
  }

  getEntitiesByType(type: string): EntityItem[] {
    return this.entities.filter((e) => e.type === type);
  }

  splitTextIntoSegments(): TextSegment[] {
    if (this.entities.length === 0) {
      return [{ text: this.originalText, isEntity: false, entityType: '' }];
    }

    const segments: TextSegment[] = [];
    let lastEnd = 0;

    // 按 offset 排序
    const sorted = [...this.entities].sort((a, b) => a.offset - b.offset);

    for (const entity of sorted) {
      // 实体前的普通文本
      if (entity.offset > lastEnd) {
        segments.push({
          text: this.originalText.substring(lastEnd, entity.offset),
          isEntity: false,
          entityType: ''
        });
      }

      // 实体文本
      segments.push({
        text: entity.text,
        isEntity: true,
        entityType: entity.type
      });

      lastEnd = entity.offset + entity.text.length;
    }

    // 最后一段普通文本
    if (lastEnd < this.originalText.length) {
      segments.push({
        text: this.originalText.substring(lastEnd),
        isEntity: false,
        entityType: ''
      });
    }

    return segments;
  }
}

要点说明

  • 三种实体类型(人名/地名/机构)使用不同颜色方案,一目了然
  • splitTextIntoSegments 方法将原文按实体位置拆分为高亮片段
  • 实体列表展示完整的识别结果,包含类型、文本和位置信息

Step 5: 集成 TTS 朗读

目标: 复用第三篇的 SpeechService,实现分析结果的语音朗读。

// service/SpeechService.ets(第三篇已有,此处展示 NLP 相关调用方式)
import { textToSpeech } from '@kit.CoreSpeechKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const TAG = 'SpeechService';
const DOMAIN = 0xFF00;

export class SpeechService {
  private static ttsEngine: textToSpeech.TextToSpeechEngine | null = null;

  static async init(): Promise<void> {
    if (this.ttsEngine) return;

    try {
      this.ttsEngine = await textToSpeech.createEngine({
        language: 'zh-CN',
        person: 0,
        online: 0
      });
      hilog.info(DOMAIN, TAG, 'TTS 引擎初始化成功');
    } catch (err) {
      hilog.error(DOMAIN, TAG, `TTS 初始化失败: ${JSON.stringify(err)}`);
    }
  }

  static async speak(text: string): Promise<void> {
    await this.init();

    if (!this.ttsEngine) {
      hilog.error(DOMAIN, TAG, 'TTS 引擎未初始化');
      return;
    }

    return new Promise<void>((resolve, reject) => {
      this.ttsEngine!.speak(text, {
        requestId: `nlp_${Date.now()}`,
        callback: (err: Error) => {
          if (err) {
            hilog.error(DOMAIN, TAG, `朗读失败: ${JSON.stringify(err)}`);
            reject(err);
          } else {
            resolve();
          }
        }
      });
    });
  }

  static stop(): void {
    if (this.ttsEngine) {
      this.ttsEngine.stop();
    }
  }

  static release(): void {
    if (this.ttsEngine) {
      this.ttsEngine.shutdown();
      this.ttsEngine = null;
    }
  }
}

NlpService 中的 generateSpeechText 方法将分析结果转为自然语言摘要:

// NlpService.generateSpeechText 示例输出:
// "文本共 42 个字符,分析耗时 120 毫秒。关键词包括:鸿蒙、操作系统、万物互联。人名:任正非。地名:深圳。机构:华为。"

用户点击"朗读结果"按钮时,NlpPage 调用 SpeechService 朗读这段摘要。

Step 6: 注册路由

目标: 将 NLP 分析页面注册到首页 Navigation 路由中。

// pages/Index.ets 中的 navDestination 部分
import { NlpPage } from '../features/nlp/NlpPage';

// 在 Navigation 的 navDestination 中添加路由
.navDestination((name: string, param: Object) => {
  if (name === 'NlpPage') {
    NlpPage()
  }
})

在首页工具卡片中添加 NLP 工具入口:

// 工具卡片配置
{
  id: 'nlp-analyzer',
  name: '自然语言分析',
  description: '中文分词、关键词提取、实体识别',
  icon: $r('sys.symbol.text_format'),
  route: 'NlpPage',
  category: ToolCategory.NLP
}

效果展示

自然语言分析独立子页面

文本输入区域支持中文文本,可分别执行分词结果提取、命名实体识别,或一次执行两项分析。当前“关键词”列表直接来自 getWordSegment() 的分词结果,并非带权重的关键词排序算法;实体结果以“文本(类型)”标签显示。示例没有标签云权重、原文区间高亮、统计栏或朗读分析摘要。
在这里插入图片描述


关键代码解读

NLP 并行处理管道

const [words, entities, keywords] = await Promise.all([
  this.segmentWords(text),
  this.recognizeEntities(text),
  this.extractKeywords(text, topK)
]);

三个 NLP 接口互不依赖,使用 Promise.all 并行执行。实测中,单独调用总耗约 300ms,并行后可缩短至 120ms 左右(取决于最长的那个任务)。这是提升 NLP 应用响应速度的关键模式。

注意:Natural Language Kit 的三个接口均为端侧计算,不涉及网络请求,并行调用不会增加带宽压力。

实体高亮的文本切分算法

splitTextIntoSegments(): TextSegment[] {
  const segments: TextSegment[] = [];
  let lastEnd = 0;
  const sorted = [...this.entities].sort((a, b) => a.offset - b.offset);

  for (const entity of sorted) {
    if (entity.offset > lastEnd) {
      segments.push({ text: this.originalText.substring(lastEnd, entity.offset), isEntity: false, ... });
    }
    segments.push({ text: entity.text, isEntity: true, entityType: entity.type });
    lastEnd = entity.offset + entity.text.length;
  }

  if (lastEnd < this.originalText.length) {
    segments.push({ text: this.originalText.substring(lastEnd), isEntity: false, ... });
  }

  return segments;
}

核心思路:按实体的 offset 排序后,将原文切分为"普通文本—实体—普通文本—实体—…"的交替序列。每个片段标记是否为实体及实体类型,UI 层根据标记决定渲染样式。

词性标注说明

Natural Language Kit 的分词结果使用北大标准词性标注集:

标注 含义 标注 含义
n 名词 v 动词
a 形容词 d 副词
r 代词 m 数词
q 量词 p 介词
c 连词 u 助词

完整标注集请参考 Natural Language Kit 官方文档


总结

本文实现了自然语言分析工具,主要完成了:

  1. NlpService 服务层:统一封装分词、NER、关键词提取三大能力,支持并行调用
  2. NlpPage 分析页面:文本输入、三 Tab 结果展示、统计信息面板
  3. KeywordCloud 关键词云:标签云可视化,字号和颜色随权重变化
  4. EntityHighlight 实体高亮:原文中按类型分色高亮实体,附带实体列表
  5. TTS 朗读集成:将分析结果转为自然语言摘要并语音播报

Natural Language Kit 的端侧处理能力让文本分析无需网络,保护用户隐私的同时提供快速响应。结合关键词提取和实体识别,可以为工具箱 App 增加智能标签、内容摘要等高级功能。


下篇预告

下一篇: HarmonyOS 智能工具箱(七):收藏管理与发布优化

将实现历史记录管理、收藏功能、性能优化和多平台适配,为 App 的发布做最后准备。


参考资料

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐