Repository navigation
[Feature] 어절 매칭 엔진 구현 #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| 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, | ||
| required this.matchedUnitCount, | ||
| }); | ||
|
|
||
| final int cursor; | ||
| final double normalizedDistance; | ||
| final int skippedTokens; | ||
| final int matchedUnitCount; | ||
| } | ||
|
|
||
| /// 현재 확정 앵커 이후의 제한된 구간만 자모 편집거리로 비교한다. | ||
| final class CursorMatcher { | ||
| CursorMatcher({ | ||
| required List<ScriptToken> 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<ScriptToken> tokens; | ||
| final MatchingPolicy policy; | ||
|
|
||
| CursorMatch? match({required String hypothesis, required int anchor}) { | ||
| final hypothesisUnits = TextNormalizer.units(hypothesis); | ||
| 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, | ||
| matchedUnitCount: currentUnits.length, | ||
| ) | ||
| : 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<int> hypothesis) { | ||
| final unitBudget = (hypothesis.length * policy.lookaheadRatio).floor(); | ||
| final scriptUnits = <int>[]; | ||
| 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, | ||
| matchedUnitCount: boundary.unitEnd, | ||
| ); | ||
| } | ||
| } | ||
| return best!; | ||
| } | ||
|
|
||
| static List<int> _lastEditDistanceRow(List<int> source, List<int> target) { | ||
| var previous = List<int>.generate(target.length + 1, (index) => index); | ||
| var current = List<int>.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; | ||
| } | ||
|
|
||
| static bool _unitsEqual(List<int> first, List<int> 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| 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 = ''; | ||
| 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; | ||
|
|
||
| ReadingProgressUpdate acceptTranscript(String text, TranscriptKind kind) { | ||
| final previous = _cursor; | ||
| if (isComplete || _requiresTranscriptReset) return _update(previous); | ||
|
|
||
| if (kind == TranscriptKind.finalResult) { | ||
| final previousPendingText = _pendingFinalText; | ||
| _pendingFinalText += text; | ||
| 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 = match.cursor; | ||
| _pendingFinalText = ''; | ||
| } else if (TextNormalizer.units(_pendingFinalText).length > | ||
| pendingFinalUnitLimit) { | ||
| _pendingFinalText = ''; | ||
| } | ||
| } else { | ||
| 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); | ||
| } | ||
|
|
||
| /// 사용자의 명시적인 한 어절 뒤로 명령만 커서를 감소시킨다. | ||
| /// 호출자는 STT generation을 교체한 뒤 [beginNewTranscriptSegment]를 호출한다. | ||
| ReadingProgressUpdate moveBackOneWord() { | ||
| 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; | ||
| _pendingFinalText = ''; | ||
| _requiresTranscriptReset = false; | ||
| } | ||
|
|
||
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<int> units; | ||
| } | ||
|
|
||
| abstract final class ScriptTokenizer { | ||
| static final RegExp _word = RegExp(r'\S+', unicode: true); | ||
|
|
||
| /// 모든 공백을 경계로 나누고, 표시용 원문과 원문 범위를 보존한다. | ||
| /// 정규화 뒤 비교 단위가 없는 문장부호 전용 조각은 제외한다. | ||
| static List<ScriptToken> tokenize(String script) { | ||
| final tokens = <ScriptToken>[]; | ||
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.