- Drift 21 tables (8 catalog + 11 user + habit_dose_variants + meta_kv) with R1~R10 CHECK constraints and 19 indexes - 8 hand-crafted seed JSON catalogs in app/assets/seed/ (refs 84, protocols 34, methodologies 21, frame_patterns 30, reward_menu_items 30, break_protocols 8, common_frames 5, diet_patterns 5) - 6 domain functions: recommend_variant, compute_streak, validate_frame_level, active_habit_quota, weekly_minimum_ratio, seed_importer (transactional, idempotent) - 4 vertical-slice Riverpod screens: HabitList, HabitCreate, CheckIn, Streak - 31 unit tests passing; flutter analyze clean - OQ-5 streak semantics: missing entry ≠ explicit blank (missing = end of history; only TrackerValue.blank triggers Never-miss-twice) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
151 lines
3.8 KiB
Dart
151 lines
3.8 KiB
Dart
import '../models/frame_pattern.dart';
|
|
import '../models/habit.dart';
|
|
|
|
enum FrameValidationStatus { accept, warn, reject }
|
|
|
|
class FrameInput {
|
|
final FrameLevel level;
|
|
final String? originalText;
|
|
final String framedText;
|
|
const FrameInput({
|
|
required this.level,
|
|
required this.framedText,
|
|
this.originalText,
|
|
});
|
|
}
|
|
|
|
class AvoidanceHit {
|
|
final String keyword;
|
|
final int startIndex;
|
|
final int endIndex;
|
|
final FramePatternModel source;
|
|
const AvoidanceHit({
|
|
required this.keyword,
|
|
required this.startIndex,
|
|
required this.endIndex,
|
|
required this.source,
|
|
});
|
|
}
|
|
|
|
class FrameSuggestion {
|
|
final FrameLevel level;
|
|
final String text;
|
|
final FramePatternModel source;
|
|
const FrameSuggestion({
|
|
required this.level,
|
|
required this.text,
|
|
required this.source,
|
|
});
|
|
}
|
|
|
|
class FrameValidationResult {
|
|
final FrameValidationStatus status;
|
|
final List<AvoidanceHit> avoidanceHits;
|
|
final List<FrameSuggestion> suggestions;
|
|
const FrameValidationResult({
|
|
required this.status,
|
|
this.avoidanceHits = const [],
|
|
this.suggestions = const [],
|
|
});
|
|
}
|
|
|
|
/// fn-validate-frame-level: R3 (L0/L1 reject) + R7 (avoidance keywords).
|
|
FrameValidationResult validateFrameLevel(
|
|
FrameInput input, {
|
|
required Iterable<FramePatternModel> knownPatterns,
|
|
}) {
|
|
if (input.framedText.isEmpty) {
|
|
return const FrameValidationResult(
|
|
status: FrameValidationStatus.reject,
|
|
);
|
|
}
|
|
|
|
if (input.level == FrameLevel.l0 || input.level == FrameLevel.l1) {
|
|
final suggestions = _buildSuggestions(input.framedText, knownPatterns);
|
|
return FrameValidationResult(
|
|
status: FrameValidationStatus.reject,
|
|
suggestions: suggestions,
|
|
);
|
|
}
|
|
|
|
final hits = detectAvoidanceKeywords(input.framedText, knownPatterns);
|
|
if (hits.isEmpty) {
|
|
return const FrameValidationResult(status: FrameValidationStatus.accept);
|
|
}
|
|
|
|
final suggestions = _buildSuggestionsFromHits(hits);
|
|
return FrameValidationResult(
|
|
status: FrameValidationStatus.warn,
|
|
avoidanceHits: hits,
|
|
suggestions: suggestions,
|
|
);
|
|
}
|
|
|
|
List<AvoidanceHit> detectAvoidanceKeywords(
|
|
String text,
|
|
Iterable<FramePatternModel> patterns,
|
|
) {
|
|
final hits = <AvoidanceHit>[];
|
|
final seen = <String>{};
|
|
for (final p in patterns) {
|
|
var idx = text.indexOf(p.avoidanceKeyword);
|
|
while (idx >= 0) {
|
|
final key = '$idx:${p.avoidanceKeyword}';
|
|
if (!seen.contains(key)) {
|
|
seen.add(key);
|
|
hits.add(AvoidanceHit(
|
|
keyword: p.avoidanceKeyword,
|
|
startIndex: idx,
|
|
endIndex: idx + p.avoidanceKeyword.length,
|
|
source: p,
|
|
));
|
|
}
|
|
idx = text.indexOf(p.avoidanceKeyword, idx + 1);
|
|
}
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
List<FrameSuggestion> _buildSuggestions(
|
|
String text,
|
|
Iterable<FramePatternModel> patterns,
|
|
) {
|
|
final relevant = patterns
|
|
.where((p) => text.contains(p.avoidanceKeyword))
|
|
.toList();
|
|
// If no keyword matches (L0 with no detectable avoidance), suggest from
|
|
// 'general' domain.
|
|
final pool = relevant.isEmpty
|
|
? patterns.where((p) => p.domain == 'general').toList()
|
|
: relevant;
|
|
return _expandSuggestions(pool);
|
|
}
|
|
|
|
List<FrameSuggestion> _buildSuggestionsFromHits(List<AvoidanceHit> hits) {
|
|
final unique = <String, FramePatternModel>{};
|
|
for (final h in hits) {
|
|
unique[h.source.id] = h.source;
|
|
}
|
|
return _expandSuggestions(unique.values.toList());
|
|
}
|
|
|
|
List<FrameSuggestion> _expandSuggestions(List<FramePatternModel> sources) {
|
|
final out = <FrameSuggestion>[];
|
|
for (final p in sources) {
|
|
out.add(FrameSuggestion(
|
|
level: FrameLevel.l2,
|
|
text: p.l2Suggestion,
|
|
source: p,
|
|
));
|
|
if (p.l3Identity != null) {
|
|
out.add(FrameSuggestion(
|
|
level: FrameLevel.l3,
|
|
text: p.l3Identity!,
|
|
source: p,
|
|
));
|
|
}
|
|
if (out.length >= 5) break;
|
|
}
|
|
return out.take(5).toList();
|
|
}
|