import '../../data/ai/llm_service.dart'; import '../frame/validate_frame_level.dart'; import '../models/frame_pattern.dart'; import '../models/habit.dart'; import 'few_shot_builder.dart'; import 'frame_candidate.dart'; import 'parse_response.dart'; /// JSON schema (function-calling parameters) for the model output. const Map kFrameCandidatesSchema = { 'type': 'object', 'properties': { 'candidates': { 'type': 'array', 'minItems': 1, 'maxItems': 3, 'items': { 'type': 'object', 'properties': { 'level': {'type': 'string', 'enum': ['L2', 'L3']}, 'framed_text': {'type': 'string', 'maxLength': 120}, 'confidence': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'source_pattern_id': {'type': 'string'}, }, 'required': ['level', 'framed_text'], }, }, }, 'required': ['candidates'], }; const Duration _defaultTimeout = Duration(seconds: 10); const int _maxRawTextLength = 200; /// Main domain function (#215 §A). Pure-ish: depends only on injected /// [llm] and [framePatterns]. Never throws — failure returns an empty list /// so callers (UI) can decide messaging (graceful degradation, AC-9). Future> suggestFrame( SuggestFrameInput input, { required LlmService llm, required List framePatterns, Duration timeout = _defaultTimeout, }) async { final raw = input.rawText.trim(); if (raw.isEmpty || raw.length > _maxRawTextLength) { return const []; } final prompt = buildFewShotPrompt(input, framePatterns); Map json; try { json = await llm .generateStructured(prompt, kFrameCandidatesSchema) .timeout(timeout); } catch (_) { // Timeout / StateError / FormatException / anything else: graceful. return const []; } List candidates; try { candidates = parseFrameCandidates(json); } on FormatException { return const []; } // Drop L0/L1 + avoidance-violating via validateFrameLevel. final validated = []; for (final c in candidates) { final result = validateFrameLevel( FrameInput( level: c.level, framedText: c.framedText, originalText: input.rawText, ), knownPatterns: framePatterns, ); if (result.status == FrameValidationStatus.reject) continue; validated.add(c); } // AC4: Enforce L2:2 + L3:1 distribution. Take L2 first (up to 2), then L3 // (up to 1). If a slot is short, leave it short rather than pad with the // other level — graceful: caller sees fewer cards but never a wrong shape. return _shapeDistribution(validated, l2Quota: 2, l3Quota: 1); } List _shapeDistribution( List all, { required int l2Quota, required int l3Quota, }) { final out = []; var l2Taken = 0; var l3Taken = 0; for (final c in all) { if (c.level == FrameLevel.l2 && l2Taken < l2Quota) { out.add(c); l2Taken += 1; } else if (c.level == FrameLevel.l3 && l3Taken < l3Quota) { out.add(c); l3Taken += 1; } if (l2Taken >= l2Quota && l3Taken >= l3Quota) break; } return out; }