Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions lib/src/features/reading/domain/cursor_matcher.dart
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;
Comment thread
Marcorable marked this conversation as resolved.
}
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;
}
}
108 changes: 108 additions & 0 deletions lib/src/features/reading/domain/reading_progress.dart
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;
}
43 changes: 43 additions & 0 deletions lib/src/features/reading/domain/script_tokenizer.dart
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);
}
}
Loading
Loading