From b622e02fb7b8c1401eeb50565016d887fd0a49d7 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Tue, 27 Oct 2026 17:01:52 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#25]=20feat:=20=EC=96=B4=EC=A0=88=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EC=97=94=EC=A7=84=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reading/domain/cursor_matcher.dart | 142 ++++++++++++++++++ .../reading/domain/reading_progress.dart | 91 +++++++++++ .../reading/domain/script_tokenizer.dart | 43 ++++++ .../reading/domain/text_normalizer.dart | 88 +++++++++++ .../reading/domain/cursor_matcher_test.dart | 62 ++++++++ .../reading/domain/reading_progress_test.dart | 111 ++++++++++++++ .../reading/domain/script_tokenizer_test.dart | 23 +++ .../reading/domain/text_normalizer_test.dart | 23 +++ 8 files changed, 583 insertions(+) create mode 100644 lib/src/features/reading/domain/cursor_matcher.dart create mode 100644 lib/src/features/reading/domain/reading_progress.dart create mode 100644 lib/src/features/reading/domain/script_tokenizer.dart create mode 100644 lib/src/features/reading/domain/text_normalizer.dart create mode 100644 test/features/reading/domain/cursor_matcher_test.dart create mode 100644 test/features/reading/domain/reading_progress_test.dart create mode 100644 test/features/reading/domain/script_tokenizer_test.dart create mode 100644 test/features/reading/domain/text_normalizer_test.dart diff --git a/lib/src/features/reading/domain/cursor_matcher.dart b/lib/src/features/reading/domain/cursor_matcher.dart new file mode 100644 index 0000000..61239b0 --- /dev/null +++ b/lib/src/features/reading/domain/cursor_matcher.dart @@ -0,0 +1,142 @@ +import 'script_tokenizer.dart'; +import 'text_normalizer.dart'; + +final class MatchingPolicy { + const MatchingPolicy({ + this.maxNormalizedDistance = 0.45, + this.lookaheadRatio = 1.6, + this.minimumLookaheadTokens = 4, + this.minimumHypothesisUnits = 3, + this.maxSkipTokens = 6, + this.skipPenalty = 0.05, + }) : assert(maxNormalizedDistance >= 0 && maxNormalizedDistance <= 1), + assert(lookaheadRatio > 0), + assert(minimumLookaheadTokens > 0), + assert(minimumHypothesisUnits > 0), + assert(maxSkipTokens >= 0), + assert(skipPenalty >= 0); + + final double maxNormalizedDistance; + final double lookaheadRatio; + final int minimumLookaheadTokens; + final int minimumHypothesisUnits; + final int maxSkipTokens; + final double skipPenalty; +} + +final class CursorMatch { + const CursorMatch({ + required this.cursor, + required this.normalizedDistance, + required this.skippedTokens, + }); + + final int cursor; + final double normalizedDistance; + final int skippedTokens; +} + +/// 현재 확정 앵커 이후의 제한된 구간만 자모 편집거리로 비교한다. +final class CursorMatcher { + CursorMatcher({ + required List tokens, + this.policy = const MatchingPolicy(), + }) : tokens = List.unmodifiable(tokens); + + factory CursorMatcher.fromScript( + String script, { + MatchingPolicy policy = const MatchingPolicy(), + }) => CursorMatcher(tokens: ScriptTokenizer.tokenize(script), policy: policy); + + final List tokens; + final MatchingPolicy policy; + + CursorMatch? match({required String hypothesis, required int anchor}) { + final hypothesisUnits = TextNormalizer.units(hypothesis); + if (hypothesisUnits.length < policy.minimumHypothesisUnits || + anchor < 0 || + anchor >= tokens.length) { + return null; + } + + CursorMatch? best; + for (var skipped = 0; skipped <= policy.maxSkipTokens; skipped++) { + final start = anchor + skipped; + if (start >= tokens.length) break; + final candidate = _alignFrom(start, skipped, hypothesisUnits); + final candidateScore = + candidate.normalizedDistance + + (skipped == 0 ? 0 : policy.skipPenalty); + final bestScore = best == null + ? double.infinity + : best.normalizedDistance + + (best.skippedTokens == 0 ? 0 : policy.skipPenalty); + if (candidateScore < bestScore) best = candidate; + } + + return best != null && + best.normalizedDistance <= policy.maxNormalizedDistance + ? best + : null; + } + + CursorMatch _alignFrom(int start, int skipped, List hypothesis) { + final unitBudget = (hypothesis.length * policy.lookaheadRatio).floor(); + final scriptUnits = []; + final boundaries = <({int cursor, int unitEnd})>[]; + for (var index = start; index < tokens.length; index++) { + final units = tokens[index].units; + final withinBudget = scriptUnits.length + units.length <= unitBudget; + final withinMinimum = index - start < policy.minimumLookaheadTokens; + if (!withinBudget && !withinMinimum) break; + scriptUnits.addAll(units); + boundaries.add((cursor: index + 1, unitEnd: scriptUnits.length)); + } + + final distances = _lastEditDistanceRow(hypothesis, scriptUnits); + CursorMatch? best; + for (final boundary in boundaries) { + final distance = distances[boundary.unitEnd]; + final normalized = + distance / + (hypothesis.length > boundary.unitEnd + ? hypothesis.length + : boundary.unitEnd); + if (best == null || normalized < best.normalizedDistance) { + best = CursorMatch( + cursor: boundary.cursor, + normalizedDistance: normalized, + skippedTokens: skipped, + ); + } + } + return best!; + } + + static List _lastEditDistanceRow(List source, List target) { + var previous = List.generate(target.length + 1, (index) => index); + var current = List.filled(target.length + 1, 0); + for (var sourceIndex = 1; sourceIndex <= source.length; sourceIndex++) { + current[0] = sourceIndex; + for (var targetIndex = 1; targetIndex <= target.length; targetIndex++) { + final substitution = source[sourceIndex - 1] == target[targetIndex - 1] + ? 0 + : 1; + current[targetIndex] = _minimum( + previous[targetIndex] + 1, + current[targetIndex - 1] + 1, + previous[targetIndex - 1] + substitution, + ); + } + final swap = previous; + previous = current; + current = swap; + } + return previous; + } + + static int _minimum(int first, int second, int third) { + final pair = first < second ? first : second; + return pair < third ? pair : third; + } +} diff --git a/lib/src/features/reading/domain/reading_progress.dart b/lib/src/features/reading/domain/reading_progress.dart new file mode 100644 index 0000000..2492d89 --- /dev/null +++ b/lib/src/features/reading/domain/reading_progress.dart @@ -0,0 +1,91 @@ +import 'cursor_matcher.dart'; +import 'text_normalizer.dart'; + +enum TranscriptKind { partial, finalResult } + +final class ReadingProgressUpdate { + const ReadingProgressUpdate({ + required this.previousCursor, + required this.cursor, + required this.repetitionCompleted, + }); + + final int previousCursor; + final int cursor; + + /// 이번 적용으로 마지막 어절에 처음 도달했을 때만 true다. + final bool repetitionCompleted; + + bool get cursorChanged => previousCursor != cursor; +} + +/// 한 반복 안의 전사 진행 상태다. 다음 반복 시작 결정은 상위 세션이 맡는다. +final class ReadingProgress { + ReadingProgress({required this.matcher, this.pendingFinalUnitLimit = 120}) + : assert(pendingFinalUnitLimit > 0); + + final CursorMatcher matcher; + final int pendingFinalUnitLimit; + + int anchor = 0; + int cursor = 0; + String _pendingFinalText = ''; + + String get pendingFinalText => _pendingFinalText; + bool get isComplete => + matcher.tokens.isNotEmpty && cursor == matcher.tokens.length; + + ReadingProgressUpdate acceptTranscript(String text, TranscriptKind kind) { + final previous = cursor; + if (isComplete) return _update(previous); + + if (kind == TranscriptKind.finalResult) { + _pendingFinalText += text; + final match = matcher.match( + hypothesis: _pendingFinalText, + anchor: anchor, + ); + if (match != null) { + cursor = _furthest(cursor, match.cursor); + anchor = cursor; + _pendingFinalText = ''; + } else if (TextNormalizer.units(_pendingFinalText).length > + pendingFinalUnitLimit) { + anchor = cursor; + _pendingFinalText = ''; + } + } else { + final match = matcher.match( + hypothesis: _pendingFinalText + text, + anchor: anchor, + ); + if (match != null) cursor = _furthest(cursor, match.cursor); + } + return _update(previous); + } + + /// 사용자의 명시적인 한 어절 뒤로 명령만 커서를 감소시킨다. + ReadingProgressUpdate moveBackOneWord() { + final previous = cursor; + if (cursor > 0) cursor--; + anchor = cursor; + _pendingFinalText = ''; + return _update(previous); + } + + /// 상위 반복 세션이 generation을 교체한 뒤 호출할 반복 경계 계약이다. + void resetForNextRepetition() { + anchor = 0; + cursor = 0; + _pendingFinalText = ''; + } + + ReadingProgressUpdate _update(int previous) => ReadingProgressUpdate( + previousCursor: previous, + cursor: cursor, + repetitionCompleted: previous < matcher.tokens.length && isComplete, + ); + + static int _furthest(int current, int candidate) => + current > candidate ? current : candidate; +} diff --git a/lib/src/features/reading/domain/script_tokenizer.dart b/lib/src/features/reading/domain/script_tokenizer.dart new file mode 100644 index 0000000..2134497 --- /dev/null +++ b/lib/src/features/reading/domain/script_tokenizer.dart @@ -0,0 +1,43 @@ +import 'text_normalizer.dart'; + +/// [start]와 [end]는 원문 String의 UTF-16 오프셋이며 [end]는 범위 밖이다. +final class ScriptToken { + const ScriptToken({ + required this.index, + required this.text, + required this.start, + required this.end, + required this.units, + }); + + final int index; + final String text; + final int start; + final int end; + final List units; +} + +abstract final class ScriptTokenizer { + static final RegExp _word = RegExp(r'\S+', unicode: true); + + /// 모든 공백을 경계로 나누고, 표시용 원문과 원문 범위를 보존한다. + /// 정규화 뒤 비교 단위가 없는 문장부호 전용 조각은 제외한다. + static List tokenize(String script) { + final tokens = []; + for (final match in _word.allMatches(script)) { + final text = match.group(0)!; + final units = TextNormalizer.units(text); + if (units.isEmpty) continue; + tokens.add( + ScriptToken( + index: tokens.length, + text: text, + start: match.start, + end: match.end, + units: units, + ), + ); + } + return List.unmodifiable(tokens); + } +} diff --git a/lib/src/features/reading/domain/text_normalizer.dart b/lib/src/features/reading/domain/text_normalizer.dart new file mode 100644 index 0000000..fbb6fac --- /dev/null +++ b/lib/src/features/reading/domain/text_normalizer.dart @@ -0,0 +1,88 @@ +/// STT 전사와 대본을 같은 비교 단위로 바꾸는 순수 Dart 정규화기다. +abstract final class TextNormalizer { + static final RegExp _digitRun = RegExp(r'[0-9]+'); + + /// 공백·문장부호·기호를 제거하고 영문은 소문자, 숫자는 한글 읽기로 + /// 바꾼 뒤 한글 음절을 초성·중성·종성 코드 포인트로 분해한다. + static List units(String text) { + final expanded = text.replaceAllMapped( + _digitRun, + (match) => _readNumber(match.group(0)!), + ); + final result = []; + + for (final rune in expanded.toLowerCase().runes) { + if (_isIgnored(rune)) continue; + if (rune >= 0xac00 && rune <= 0xd7a3) { + final offset = rune - 0xac00; + result + ..add(0x1100 + offset ~/ 588) + ..add(0x1161 + (offset % 588) ~/ 28); + final trailing = offset % 28; + if (trailing != 0) result.add(0x11a7 + trailing); + } else { + result.add(rune); + } + } + return List.unmodifiable(result); + } + + static bool _isIgnored(int rune) { + return rune <= 0x20 || + (rune >= 0x21 && rune <= 0x2f) || + (rune >= 0x3a && rune <= 0x40) || + (rune >= 0x5b && rune <= 0x60) || + (rune >= 0x7b && rune <= 0x7e) || + (rune >= 0x20a0 && rune <= 0x20cf) || + (rune >= 0x2100 && rune <= 0x2bff) || + (rune >= 0x2000 && rune <= 0x206f) || + (rune >= 0x3000 && rune <= 0x303f) || + (rune >= 0x1f000 && rune <= 0x1faff) || + (rune >= 0xff01 && rune <= 0xff0f) || + (rune >= 0xff1a && rune <= 0xff20) || + (rune >= 0xff3b && rune <= 0xff40) || + (rune >= 0xff5b && rune <= 0xff65); + } + + static String _readNumber(String digits) { + if (digits.length > 4 || (digits.length > 1 && digits.startsWith('0'))) { + return digits.split('').map(_digitName).join(); + } + + final value = int.parse(digits); + if (value == 0) return '영'; + const units = ['', '십', '백', '천']; + final buffer = StringBuffer(); + var remaining = value; + for (var position = 3; position >= 0; position--) { + final divisor = _powerOfTen(position); + final digit = remaining ~/ divisor; + remaining %= divisor; + if (digit == 0) continue; + if (digit != 1 || position == 0) buffer.write(_digitName('$digit')); + buffer.write(units[position]); + } + return buffer.toString(); + } + + static int _powerOfTen(int exponent) { + var value = 1; + for (var i = 0; i < exponent; i++) { + value *= 10; + } + return value; + } + + static String _digitName(String digit) => const { + '0': '영', + '1': '일', + '2': '이', + '3': '삼', + '4': '사', + '5': '오', + '6': '육', + '7': '칠', + '8': '팔', + '9': '구', + }[digit]!; +} diff --git a/test/features/reading/domain/cursor_matcher_test.dart b/test/features/reading/domain/cursor_matcher_test.dart new file mode 100644 index 0000000..386fd42 --- /dev/null +++ b/test/features/reading/domain/cursor_matcher_test.dart @@ -0,0 +1,62 @@ +import 'package:maltto/src/features/reading/domain/cursor_matcher.dart'; +import 'package:test/test.dart'; + +void main() { + const script = '오늘 아침에는 창문을 열고 바깥 공기를 천천히 마셨다'; + + group('CursorMatcher', () { + test('정확한 발화를 어절 경계까지 매칭한다', () { + final matcher = CursorMatcher.fromScript(script); + + expect(matcher.match(hypothesis: '오늘', anchor: 0)?.cursor, 1); + expect(matcher.match(hypothesis: '오늘 아침에는', anchor: 0)?.cursor, 2); + expect(matcher.match(hypothesis: '오늘 아침에는 창문을 열고', anchor: 0)?.cursor, 4); + }); + + test('띄어쓰기 차이와 작은 한국어 오인식을 허용한다', () { + final matcher = CursorMatcher.fromScript(script); + + expect(matcher.match(hypothesis: '오늘아침에는창문을', anchor: 0)?.cursor, 3); + final mistaken = matcher.match(hypothesis: '오늘 아침에는 창문은 열고', anchor: 0); + expect(mistaken?.cursor, 4); + expect(mistaken!.normalizedDistance, lessThan(0.1)); + }); + + test('마지막 어절 일부만 발화하면 앞 경계를 유지한다', () { + final matcher = CursorMatcher.fromScript(script); + + expect(matcher.match(hypothesis: '오늘 아침에는 창', anchor: 0)?.cursor, 2); + }); + + test('무관한 발화와 너무 짧은 발화를 거부한다', () { + final matcher = CursorMatcher.fromScript(script); + + expect(matcher.match(hypothesis: '점심 메뉴를 고른다', anchor: 0), isNull); + expect(matcher.match(hypothesis: '오', anchor: 0), isNull); + }); + + test('현재 앵커 이후에서만 반복 어절을 찾는다', () { + final matcher = CursorMatcher.fromScript('가나다 라마바 가나다 라마바 사아자'); + + expect(matcher.match(hypothesis: '가나다 라마바', anchor: 2)?.cursor, 4); + }); + + test('정책의 제한 범위 안에서만 중간 시작을 허용한다', () { + final matcher = CursorMatcher.fromScript( + script, + policy: const MatchingPolicy(maxSkipTokens: 2), + ); + + final within = matcher.match(hypothesis: '창문을 열고 바깥', anchor: 0); + expect(within?.cursor, 5); + expect(within?.skippedTokens, 2); + expect(matcher.match(hypothesis: '천천히 마셨다', anchor: 0), isNull); + }); + + test('반복 어절의 동률에서는 건너뛰지 않는다', () { + final matcher = CursorMatcher.fromScript('가나다 가나다 가나다 라마바'); + + expect(matcher.match(hypothesis: '가나다', anchor: 0)?.skippedTokens, 0); + }); + }); +} diff --git a/test/features/reading/domain/reading_progress_test.dart b/test/features/reading/domain/reading_progress_test.dart new file mode 100644 index 0000000..ef9efcb --- /dev/null +++ b/test/features/reading/domain/reading_progress_test.dart @@ -0,0 +1,111 @@ +import 'package:maltto/src/features/reading/domain/cursor_matcher.dart'; +import 'package:maltto/src/features/reading/domain/reading_progress.dart'; +import 'package:test/test.dart'; + +void main() { + const script = '오늘 아침에는 창문을 열고 바깥 공기를 천천히 마셨다'; + + ReadingProgress createProgress({int pendingFinalUnitLimit = 120}) { + return ReadingProgress( + matcher: CursorMatcher.fromScript(script), + pendingFinalUnitLimit: pendingFinalUnitLimit, + ); + } + + group('ReadingProgress', () { + test('partial은 커서만 진행하고 final은 앵커를 확정한다', () { + final progress = createProgress(); + + progress.acceptTranscript('오늘 아침에는', TranscriptKind.partial); + expect(progress.cursor, 2); + expect(progress.anchor, 0); + + progress.acceptTranscript('오늘 아침에는 창문을', TranscriptKind.finalResult); + expect(progress.cursor, 3); + expect(progress.anchor, 3); + expect(progress.pendingFinalText, isEmpty); + }); + + test('partial이 반복되거나 짧게 수정돼도 자동 후퇴하지 않는다', () { + final progress = createProgress(); + + progress.acceptTranscript('오늘 아침에는 창문을 열고', TranscriptKind.partial); + progress.acceptTranscript('오늘 아침에는 창문을 열고', TranscriptKind.partial); + progress.acceptTranscript('오늘 아침', TranscriptKind.partial); + + expect(progress.cursor, 4); + expect(progress.anchor, 0); + }); + + test('연속 final과 합쳐진 짧은 final을 처리한다', () { + final progress = createProgress(); + + progress.acceptTranscript('오', TranscriptKind.finalResult); + expect(progress.cursor, 0); + expect(progress.pendingFinalText, '오'); + progress.acceptTranscript('늘 아침에는', TranscriptKind.finalResult); + progress.acceptTranscript('창문을 열고', TranscriptKind.finalResult); + + expect(progress.cursor, 4); + expect(progress.anchor, 4); + }); + + test('긴 무관 발화를 버린 뒤 대본으로 복귀한다', () { + final progress = createProgress(pendingFinalUnitLimit: 10); + + progress.acceptTranscript( + '전혀 다른 이야기를 길게 합니다', + TranscriptKind.finalResult, + ); + expect(progress.cursor, 0); + expect(progress.pendingFinalText, isEmpty); + progress.acceptTranscript('오늘 아침에는', TranscriptKind.finalResult); + expect(progress.cursor, 2); + }); + + test('명시적인 한 어절 뒤로 명령만 커서를 감소시킨다', () { + final progress = createProgress(); + progress.acceptTranscript('오늘 아침에는 창문을', TranscriptKind.finalResult); + + final update = progress.moveBackOneWord(); + + expect(update.previousCursor, 3); + expect(update.cursor, 2); + expect(progress.anchor, 2); + progress.moveBackOneWord(); + progress.moveBackOneWord(); + final atStart = progress.moveBackOneWord(); + expect(atStart.cursor, 0); + expect(atStart.cursorChanged, isFalse); + }); + + test('마지막 어절 완료 이벤트를 한 번만 만든다', () { + final progress = createProgress(); + + final completed = progress.acceptTranscript( + script, + TranscriptKind.partial, + ); + final duplicate = progress.acceptTranscript( + script, + TranscriptKind.finalResult, + ); + + expect(progress.isComplete, isTrue); + expect(completed.repetitionCompleted, isTrue); + expect(duplicate.repetitionCompleted, isFalse); + }); + + test('반복 경계에서 전사 상태와 커서를 초기화한다', () { + final progress = createProgress(); + progress.acceptTranscript(script, TranscriptKind.finalResult); + + progress.resetForNextRepetition(); + final next = progress.acceptTranscript('오늘 아침에는', TranscriptKind.partial); + + expect(next.cursor, 2); + expect(progress.anchor, 0); + expect(progress.pendingFinalText, isEmpty); + }); + }); +} diff --git a/test/features/reading/domain/script_tokenizer_test.dart b/test/features/reading/domain/script_tokenizer_test.dart new file mode 100644 index 0000000..598b776 --- /dev/null +++ b/test/features/reading/domain/script_tokenizer_test.dart @@ -0,0 +1,23 @@ +import 'package:maltto/src/features/reading/domain/script_tokenizer.dart'; +import 'package:test/test.dart'; + +void main() { + group('ScriptTokenizer', () { + test('모든 공백으로 어절을 나누고 문장부호 전용 조각은 버린다', () { + final tokens = ScriptTokenizer.tokenize('첫째 줄\n둘째\t줄 — 😀 셋째'); + + expect(tokens.map((token) => token.text), ['첫째', '줄', '둘째', '줄', '셋째']); + expect(tokens.map((token) => token.index), [0, 1, 2, 3, 4]); + }); + + test('UTF-16 원문 범위로 표시 텍스트를 그대로 복원한다', () { + const script = ' 맑은 날,\n천천히 읽는다.'; + final tokens = ScriptTokenizer.tokenize(script); + + expect(tokens.map((token) => token.text), ['맑은', '날,', '천천히', '읽는다.']); + for (final token in tokens) { + expect(script.substring(token.start, token.end), token.text); + } + }); + }); +} diff --git a/test/features/reading/domain/text_normalizer_test.dart b/test/features/reading/domain/text_normalizer_test.dart new file mode 100644 index 0000000..244b772 --- /dev/null +++ b/test/features/reading/domain/text_normalizer_test.dart @@ -0,0 +1,23 @@ +import 'package:maltto/src/features/reading/domain/text_normalizer.dart'; +import 'package:test/test.dart'; + +void main() { + group('TextNormalizer', () { + test('공백과 문장부호를 제거하고 영문 대소문자를 통일한다', () { + expect( + TextNormalizer.units('새 문장, ABC!'), + TextNormalizer.units('새문장abc'), + ); + }); + + test('한글 음절을 자모 단위로 분해한다', () { + expect(TextNormalizer.units('가'), hasLength(2)); + expect(TextNormalizer.units('각'), hasLength(3)); + }); + + test('STT의 아라비아 숫자를 한글 수 읽기로 바꾼다', () { + expect(TextNormalizer.units('10분'), TextNormalizer.units('십 분')); + expect(TextNormalizer.units('203번'), TextNormalizer.units('이백삼 번')); + }); + }); +} From f96aca598787c7ddd9aa5f8e337a26551f0f7aa3 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Tue, 27 Oct 2026 19:00:11 +0900 Subject: [PATCH 2/4] [#25] fix: Code Review 1 --- .../reading/domain/cursor_matcher.dart | 22 ++++++-- .../reading/domain/reading_progress.dart | 54 ++++++++++++------- .../reading/domain/cursor_matcher_test.dart | 8 +++ .../reading/domain/reading_progress_test.dart | 54 +++++++++++++++++++ 4 files changed, 115 insertions(+), 23 deletions(-) diff --git a/lib/src/features/reading/domain/cursor_matcher.dart b/lib/src/features/reading/domain/cursor_matcher.dart index 61239b0..7d0c0fb 100644 --- a/lib/src/features/reading/domain/cursor_matcher.dart +++ b/lib/src/features/reading/domain/cursor_matcher.dart @@ -53,11 +53,19 @@ final class CursorMatcher { CursorMatch? match({required String hypothesis, required int anchor}) { final hypothesisUnits = TextNormalizer.units(hypothesis); - if (hypothesisUnits.length < policy.minimumHypothesisUnits || - anchor < 0 || - anchor >= tokens.length) { + if (anchor < 0 || anchor >= tokens.length) { return null; } + if (hypothesisUnits.length < policy.minimumHypothesisUnits) { + final currentUnits = tokens[anchor].units; + return _unitsEqual(hypothesisUnits, currentUnits) + ? CursorMatch( + cursor: anchor + 1, + normalizedDistance: 0, + skippedTokens: 0, + ) + : null; + } CursorMatch? best; for (var skipped = 0; skipped <= policy.maxSkipTokens; skipped++) { @@ -139,4 +147,12 @@ final class CursorMatcher { final pair = first < second ? first : second; return pair < third ? pair : third; } + + static bool _unitsEqual(List first, List second) { + if (first.length != second.length) return false; + for (var index = 0; index < first.length; index++) { + if (first[index] != second[index]) return false; + } + return true; + } } diff --git a/lib/src/features/reading/domain/reading_progress.dart b/lib/src/features/reading/domain/reading_progress.dart index 2492d89..cec5435 100644 --- a/lib/src/features/reading/domain/reading_progress.dart +++ b/lib/src/features/reading/domain/reading_progress.dart @@ -27,62 +27,76 @@ final class ReadingProgress { final CursorMatcher matcher; final int pendingFinalUnitLimit; - int anchor = 0; - int cursor = 0; + int _anchor = 0; + int _cursor = 0; String _pendingFinalText = ''; + bool _requiresTranscriptReset = false; + int get anchor => _anchor; + int get cursor => _cursor; String get pendingFinalText => _pendingFinalText; + bool get requiresTranscriptReset => _requiresTranscriptReset; bool get isComplete => - matcher.tokens.isNotEmpty && cursor == matcher.tokens.length; + matcher.tokens.isNotEmpty && _cursor == matcher.tokens.length; ReadingProgressUpdate acceptTranscript(String text, TranscriptKind kind) { - final previous = cursor; - if (isComplete) return _update(previous); + final previous = _cursor; + if (isComplete || _requiresTranscriptReset) return _update(previous); if (kind == TranscriptKind.finalResult) { + final previousPendingText = _pendingFinalText; _pendingFinalText += text; - final match = matcher.match( - hypothesis: _pendingFinalText, - anchor: anchor, - ); + var match = matcher.match(hypothesis: _pendingFinalText, anchor: _anchor); + if (match == null && previousPendingText.isNotEmpty) { + match = matcher.match(hypothesis: text, anchor: _anchor); + } if (match != null) { - cursor = _furthest(cursor, match.cursor); - anchor = cursor; + _cursor = _furthest(_cursor, match.cursor); + _anchor = _cursor; _pendingFinalText = ''; } else if (TextNormalizer.units(_pendingFinalText).length > pendingFinalUnitLimit) { - anchor = cursor; + _anchor = _cursor; _pendingFinalText = ''; } } else { final match = matcher.match( hypothesis: _pendingFinalText + text, - anchor: anchor, + anchor: _anchor, ); - if (match != null) cursor = _furthest(cursor, match.cursor); + if (match != null) _cursor = _furthest(_cursor, match.cursor); } return _update(previous); } /// 사용자의 명시적인 한 어절 뒤로 명령만 커서를 감소시킨다. + /// 호출자는 STT generation을 교체한 뒤 [beginNewTranscriptSegment]를 호출한다. ReadingProgressUpdate moveBackOneWord() { - final previous = cursor; - if (cursor > 0) cursor--; - anchor = cursor; + final previous = _cursor; + if (_cursor > 0) _cursor--; + _anchor = _cursor; _pendingFinalText = ''; + _requiresTranscriptReset = true; return _update(previous); } + /// 후퇴 전에 시작된 늦은 전사 이벤트를 모두 폐기한 뒤 새 구간을 연다. + void beginNewTranscriptSegment() { + _pendingFinalText = ''; + _requiresTranscriptReset = false; + } + /// 상위 반복 세션이 generation을 교체한 뒤 호출할 반복 경계 계약이다. void resetForNextRepetition() { - anchor = 0; - cursor = 0; + _anchor = 0; + _cursor = 0; _pendingFinalText = ''; + _requiresTranscriptReset = false; } ReadingProgressUpdate _update(int previous) => ReadingProgressUpdate( previousCursor: previous, - cursor: cursor, + cursor: _cursor, repetitionCompleted: previous < matcher.tokens.length && isComplete, ); diff --git a/test/features/reading/domain/cursor_matcher_test.dart b/test/features/reading/domain/cursor_matcher_test.dart index 386fd42..a3f0eff 100644 --- a/test/features/reading/domain/cursor_matcher_test.dart +++ b/test/features/reading/domain/cursor_matcher_test.dart @@ -35,6 +35,14 @@ void main() { expect(matcher.match(hypothesis: '오', anchor: 0), isNull); }); + test('현재 위치의 짧은 어절은 정확히 일치할 때만 허용한다', () { + final matcher = CursorMatcher.fromScript('길게 읽고 나'); + + expect(matcher.match(hypothesis: '나', anchor: 2)?.cursor, 3); + expect(matcher.match(hypothesis: '너', anchor: 2), isNull); + expect(matcher.match(hypothesis: '나', anchor: 0), isNull); + }); + test('현재 앵커 이후에서만 반복 어절을 찾는다', () { final matcher = CursorMatcher.fromScript('가나다 라마바 가나다 라마바 사아자'); diff --git a/test/features/reading/domain/reading_progress_test.dart b/test/features/reading/domain/reading_progress_test.dart index ef9efcb..5091e45 100644 --- a/test/features/reading/domain/reading_progress_test.dart +++ b/test/features/reading/domain/reading_progress_test.dart @@ -63,6 +63,25 @@ void main() { expect(progress.cursor, 2); }); + test('불일치 final 누적 뒤 새 final 단독 매칭으로 복귀한다', () { + final progress = createProgress(); + progress.acceptTranscript( + '오늘 아침에는 창문을 열고 바깥 공기를 천천히', + TranscriptKind.finalResult, + ); + expect(progress.cursor, 7); + + progress.acceptTranscript('점심 메뉴를 고른다', TranscriptKind.finalResult); + final completed = progress.acceptTranscript( + '마셨다', + TranscriptKind.finalResult, + ); + + expect(completed.cursor, 8); + expect(completed.repetitionCompleted, isTrue); + expect(progress.pendingFinalText, isEmpty); + }); + test('명시적인 한 어절 뒤로 명령만 커서를 감소시킨다', () { final progress = createProgress(); progress.acceptTranscript('오늘 아침에는 창문을', TranscriptKind.finalResult); @@ -72,6 +91,7 @@ void main() { expect(update.previousCursor, 3); expect(update.cursor, 2); expect(progress.anchor, 2); + expect(progress.requiresTranscriptReset, isTrue); progress.moveBackOneWord(); progress.moveBackOneWord(); final atStart = progress.moveBackOneWord(); @@ -79,6 +99,27 @@ void main() { expect(atStart.cursorChanged, isFalse); }); + test('후퇴 전 전사는 무시하고 새 전사 구간에서 추적을 재개한다', () { + final progress = createProgress(); + progress.acceptTranscript( + '오늘 아침에는 창문을 열고 바깥 공기를 천천히', + TranscriptKind.partial, + ); + progress.moveBackOneWord(); + + final stale = progress.acceptTranscript(script, TranscriptKind.partial); + expect(stale.cursor, 6); + expect(stale.cursorChanged, isFalse); + + progress.beginNewTranscriptSegment(); + final resumed = progress.acceptTranscript( + '천천히 마셨다', + TranscriptKind.finalResult, + ); + expect(resumed.cursor, 8); + expect(resumed.repetitionCompleted, isTrue); + }); + test('마지막 어절 완료 이벤트를 한 번만 만든다', () { final progress = createProgress(); @@ -96,6 +137,19 @@ void main() { expect(duplicate.repetitionCompleted, isFalse); }); + test('짧은 단일 어절도 정확히 읽으면 완료한다', () { + final progress = ReadingProgress(matcher: CursorMatcher.fromScript('나')); + + final completed = progress.acceptTranscript( + '나', + TranscriptKind.finalResult, + ); + + expect(completed.cursor, 1); + expect(completed.repetitionCompleted, isTrue); + expect(progress.isComplete, isTrue); + }); + test('반복 경계에서 전사 상태와 커서를 초기화한다', () { final progress = createProgress(); progress.acceptTranscript(script, TranscriptKind.finalResult); From 5a588c6e7f04cdc1312e34d1bd534a8dd48452d0 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Tue, 27 Oct 2026 19:07:03 +0900 Subject: [PATCH 3/4] [#25] fix: Code Review 2 --- .../features/reading/domain/cursor_matcher.dart | 4 ++++ .../reading/domain/reading_progress.dart | 14 +++++++++----- .../reading/domain/reading_progress_test.dart | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lib/src/features/reading/domain/cursor_matcher.dart b/lib/src/features/reading/domain/cursor_matcher.dart index 7d0c0fb..e1c00fe 100644 --- a/lib/src/features/reading/domain/cursor_matcher.dart +++ b/lib/src/features/reading/domain/cursor_matcher.dart @@ -29,11 +29,13 @@ final class CursorMatch { required this.cursor, required this.normalizedDistance, required this.skippedTokens, + required this.matchedUnitCount, }); final int cursor; final double normalizedDistance; final int skippedTokens; + final int matchedUnitCount; } /// 현재 확정 앵커 이후의 제한된 구간만 자모 편집거리로 비교한다. @@ -63,6 +65,7 @@ final class CursorMatcher { cursor: anchor + 1, normalizedDistance: 0, skippedTokens: 0, + matchedUnitCount: currentUnits.length, ) : null; } @@ -115,6 +118,7 @@ final class CursorMatcher { cursor: boundary.cursor, normalizedDistance: normalized, skippedTokens: skipped, + matchedUnitCount: boundary.unitEnd, ); } } diff --git a/lib/src/features/reading/domain/reading_progress.dart b/lib/src/features/reading/domain/reading_progress.dart index cec5435..3aa7486 100644 --- a/lib/src/features/reading/domain/reading_progress.dart +++ b/lib/src/features/reading/domain/reading_progress.dart @@ -60,11 +60,15 @@ final class ReadingProgress { _pendingFinalText = ''; } } else { - final match = matcher.match( - hypothesis: _pendingFinalText + text, - anchor: _anchor, - ); - if (match != null) _cursor = _furthest(_cursor, match.cursor); + final hypothesis = _pendingFinalText + text; + final match = matcher.match(hypothesis: hypothesis, anchor: _anchor); + final stopsBeforeLastTokenEnds = + match != null && + match.cursor == matcher.tokens.length && + TextNormalizer.units(hypothesis).length < match.matchedUnitCount; + if (match != null && !stopsBeforeLastTokenEnds) { + _cursor = _furthest(_cursor, match.cursor); + } } return _update(previous); } diff --git a/test/features/reading/domain/reading_progress_test.dart b/test/features/reading/domain/reading_progress_test.dart index 5091e45..4afbf77 100644 --- a/test/features/reading/domain/reading_progress_test.dart +++ b/test/features/reading/domain/reading_progress_test.dart @@ -150,6 +150,23 @@ void main() { expect(progress.isComplete, isTrue); }); + test('마지막 어절의 미완성 partial은 완료하지 않는다', () { + final progress = ReadingProgress( + matcher: CursorMatcher.fromScript('마셨다'), + ); + + final partial = progress.acceptTranscript('마셨', TranscriptKind.partial); + final completed = progress.acceptTranscript( + '마셨다', + TranscriptKind.finalResult, + ); + + expect(partial.cursor, 0); + expect(partial.repetitionCompleted, isFalse); + expect(completed.cursor, 1); + expect(completed.repetitionCompleted, isTrue); + }); + test('반복 경계에서 전사 상태와 커서를 초기화한다', () { final progress = createProgress(); progress.acceptTranscript(script, TranscriptKind.finalResult); From 4ad55c69bf3de3677b361c05bc0185bceb3d813a Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Tue, 27 Oct 2026 19:12:02 +0900 Subject: [PATCH 4/4] [#25] fix: Code Review 3 --- .../reading/domain/reading_progress.dart | 3 +-- .../reading/domain/reading_progress_test.dart | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/src/features/reading/domain/reading_progress.dart b/lib/src/features/reading/domain/reading_progress.dart index 3aa7486..09bf4d6 100644 --- a/lib/src/features/reading/domain/reading_progress.dart +++ b/lib/src/features/reading/domain/reading_progress.dart @@ -52,11 +52,10 @@ final class ReadingProgress { } if (match != null) { _cursor = _furthest(_cursor, match.cursor); - _anchor = _cursor; + _anchor = match.cursor; _pendingFinalText = ''; } else if (TextNormalizer.units(_pendingFinalText).length > pendingFinalUnitLimit) { - _anchor = _cursor; _pendingFinalText = ''; } } else { diff --git a/test/features/reading/domain/reading_progress_test.dart b/test/features/reading/domain/reading_progress_test.dart index 4afbf77..457e340 100644 --- a/test/features/reading/domain/reading_progress_test.dart +++ b/test/features/reading/domain/reading_progress_test.dart @@ -37,6 +37,19 @@ void main() { expect(progress.anchor, 0); }); + test('짧아진 final은 partial 커서가 아닌 확정 경계까지만 앵커를 옮긴다', () { + final progress = createProgress(); + progress.acceptTranscript('오늘 아침에는 창문을 열고', TranscriptKind.partial); + + progress.acceptTranscript('오늘', TranscriptKind.finalResult); + expect(progress.cursor, 4); + expect(progress.anchor, 1); + + progress.acceptTranscript('아침에는 창문을 열고', TranscriptKind.finalResult); + expect(progress.cursor, 4); + expect(progress.anchor, 4); + }); + test('연속 final과 합쳐진 짧은 final을 처리한다', () { final progress = createProgress(); @@ -63,6 +76,20 @@ void main() { expect(progress.cursor, 2); }); + test('불일치 final을 폐기해도 partial 커서를 확정하지 않는다', () { + final progress = createProgress(pendingFinalUnitLimit: 10); + progress.acceptTranscript('오늘 아침에는 창문을 열고', TranscriptKind.partial); + + progress.acceptTranscript( + '전혀 다른 이야기를 길게 합니다', + TranscriptKind.finalResult, + ); + + expect(progress.cursor, 4); + expect(progress.anchor, 0); + expect(progress.pendingFinalText, isEmpty); + }); + test('불일치 final 누적 뒤 새 final 단독 매칭으로 복귀한다', () { final progress = createProgress(); progress.acceptTranscript(