[Developer] #204 Phase 1 MVP — Flutter app skeleton complete
- 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>
This commit is contained in:
150
app/lib/domain/frame/validate_frame_level.dart
Normal file
150
app/lib/domain/frame/validate_frame_level.dart
Normal file
@@ -0,0 +1,150 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user