diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py index 32a5aee..b9d0fea 100644 --- a/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw/__manifest__.py @@ -8,7 +8,7 @@ Covers child support modification, dissolution of marriage, and paternity cases in Miami-Dade County (11th Circuit). Includes FL 61.30 child support calculator, document generation, - deadline tracking, and AI-powered case law analysis via Ollama. + deadline tracking, and AI-powered case law analysis via Claude API. """, 'author': 'Active Blue LLC', 'website': 'https://avc.activeblue.net', @@ -31,6 +31,7 @@ 'security/fl_security.xml', 'security/ir.model.access.csv', # Seed data (load before views) + 'data/fl_stage_data.xml', 'data/fl_issue_tags.xml', 'data/fl_statute_data.xml', 'data/fl_support_schedule.xml', diff --git a/activeblue_familylaw/data/fl_stage_data.xml b/activeblue_familylaw/data/fl_stage_data.xml new file mode 100644 index 0000000..7664d06 --- /dev/null +++ b/activeblue_familylaw/data/fl_stage_data.xml @@ -0,0 +1,72 @@ + + + + + + Intake & Qualification + 10 + False + + + + Document Preparation + 20 + False + + + + Filed — Awaiting Service + 30 + False + + + + Service Complete + 40 + False + + + + Discovery + 50 + False + + + + Deposition Stage + 60 + False + + + + Mediation + 70 + False + + + + Hearing Scheduled + 80 + False + + + + Order Entered + 90 + False + + + + Closed + 100 + True + + + + Referred to Attorney + 110 + True + + + + diff --git a/activeblue_familylaw/models/fl_ai_engine.py b/activeblue_familylaw/models/fl_ai_engine.py index a908994..1af1efc 100644 --- a/activeblue_familylaw/models/fl_ai_engine.py +++ b/activeblue_familylaw/models/fl_ai_engine.py @@ -5,39 +5,27 @@ from odoo import models _logger = logging.getLogger(__name__) -OLLAMA_URL = 'http://192.168.2.10:11434/api/generate' -OLLAMA_MODEL = 'llama3.1' +CLAUDE_MODEL = 'claude-sonnet-4-20250514' # ───────────────────────────────────────────────────────────────────────────── # Rule-based issue tag weights -# Maps (field_name, value_or_True) → list of issue tag XML ids +# Maps (field_name, value_or_True) → list of issue tag names +# Applied to fl.case fields directly (employment-based rules handled separately +# via party_ids lookup in _rule_based_tagging). # ───────────────────────────────────────────────────────────────────────────── -ISSUE_RULES = [ - # Modification threshold +CASE_FIELD_RULES = [ ('threshold_met', True, ['modification_threshold']), - # Income imputation triggers - ('respondent_employment_status', 'unemployed', ['income_imputation']), - ('respondent_employment_status', 'self_employed', ['self_employment_income', 'income_imputation']), - ('petitioner_employment_status', 'self_employed', ['self_employment_income']), - # Timesharing deviation - ('substantial_timesharing', True, ['timesharing_deviation']), - # Domestic violence + ('substantial_timesharing_applies', True, ['timesharing_deviation']), ('domestic_violence_flag', True, ['domestic_violence']), - # Fee waiver ('fee_waiver_eligible', True, ['fee_waiver']), - # Default judgment track ('respondent_answered', False, ['default_judgment']), - # Residency ('residency_requirement_met', False, ['residency']), - # Parenting class ('parenting_class_required', True, ['parenting_class']), - # Post-order ('case_type', 'modification', ['post_order']), ] # ───────────────────────────────────────────────────────────────────────────── # Caselaw topic → issue tag matching -# Used by _match_caselaw to find relevant cases # ───────────────────────────────────────────────────────────────────────────── TAG_TO_CASELAW_DOMAINS = { 'modification_threshold': [('issue_tag_ids.name', '=', 'modification_threshold')], @@ -52,36 +40,33 @@ TAG_TO_CASELAW_DOMAINS = { 'post_order': [('issue_tag_ids.name', '=', 'post_order')], } -# Maximum caselaw records to pass to Ollama (keep prompt size manageable) MAX_CASELAW_IN_PROMPT = 8 class FlAiEngine(models.AbstractModel): """ - Phase 5 — Full Ollama integration with rule-based pre-processing. + AI Analysis Engine — Claude API (claude-sonnet-4-20250514). Workflow: 1. _rule_based_tagging — tag issue tags from case field values (fast, deterministic) 2. _match_caselaw — find relevant FL cases from the caselaw library 3. _build_case_context — serialize case data to a JSON dict - 4. _build_prompt — compose the Ollama prompt - 5. _call_ollama — HTTP call to Ollama with error handling + 4. _build_prompt — compose the Claude prompt + 5. _call_claude — Claude API call with error handling 6. _store_analysis — persist fl.analysis record with results + Falls back to _fallback_complexity() when Claude API is unavailable. AbstractModel — not stored in the database. """ _name = 'fl.ai.engine' - _description = 'Family Law AI Analysis Engine (Ollama)' + _description = 'Family Law AI Analysis Engine (Claude API)' # ────────────────────────────────────────────────────────────────────────── # Public entry point # ────────────────────────────────────────────────────────────────────────── def analyze_case(self, case_id): - """ - Full Phase 5 analysis entry point. - Returns fl.analysis record. - """ + """Full analysis entry point. Returns fl.analysis record.""" case = self.env['fl.case'].browse(case_id) if not case.exists(): raise ValueError(f"Case {case_id} not found") @@ -89,45 +74,35 @@ class FlAiEngine(models.AbstractModel): analysis_vals = { 'case_id': case.id, 'state': 'pending', - 'model_used': OLLAMA_MODEL, + 'model_used': CLAUDE_MODEL, } analysis = self.env['fl.analysis'].create(analysis_vals) try: - # Step 1: Rule-based tagging triggered_tags = self._rule_based_tagging(case) if triggered_tags: existing_tags = case.issue_tag_ids.mapped('name') new_tag_recs = self.env['fl.issue.tag'].search([ - ('name', 'in', triggered_tags), + ('name', 'in', list(triggered_tags)), ('name', 'not in', existing_tags), ]) if new_tag_recs: case.write({'issue_tag_ids': [(4, t.id) for t in new_tag_recs]}) - # Step 2: Match caselaw matched_cases = self._match_caselaw(triggered_tags) - - # Step 3: Build case context context = self._build_case_context(case, matched_cases) - - # Step 4: Determine complexity (used in prompt and result) complexity = self._assess_complexity(case, triggered_tags) - - # Step 5: Build prompt prompt = self._build_prompt(context, complexity) - - # Step 6: Call Ollama - result = self._call_ollama(prompt) - - # Step 7: Store results + result = self._call_claude(prompt) self._store_analysis(analysis, result, matched_cases, complexity) except Exception as exc: _logger.error("AI analysis failed for case %s: %s", case_id, exc, exc_info=True) + fallback_complexity = self._fallback_complexity(case) analysis.write({ 'state': 'failed', 'error_message': str(exc), + 'case_complexity': fallback_complexity, 'plain_english_summary': ( "AI analysis could not be completed at this time. " "Please try again later or contact support." @@ -146,38 +121,46 @@ class FlAiEngine(models.AbstractModel): def _rule_based_tagging(self, case): """ - Apply deterministic rules to identify legal issues present in the case. + Apply deterministic rules to identify legal issues in the case. Returns a set of issue tag name strings that were triggered. """ triggered = set() - for rule in ISSUE_RULES: - field_name, expected, tags = rule - if not hasattr(case, field_name): - continue - val = getattr(case, field_name) - # Handle relational fields (Many2one returns recordset) - if hasattr(val, '_name'): - continue + # Direct case field checks + for field_name, expected, tags in CASE_FIELD_RULES: + val = getattr(case, field_name, None) if val == expected: triggered.update(tags) - # Additional logic: income imputation if large discrepancy - if (case.petitioner_net_income and case.respondent_net_income - and case.respondent_employment_status not in ('unemployed', 'self_employed')): - pet = case.petitioner_net_income - resp = case.respondent_net_income - if resp > 0 and pet > 0: - ratio = max(pet, resp) / min(pet, resp) + # Employment-based checks from party_ids + petitioner_party = case.party_ids.filtered(lambda p: p.role == 'petitioner') + respondent_party = case.party_ids.filtered(lambda p: p.role == 'respondent') + pet = petitioner_party[0] if petitioner_party else None + resp = respondent_party[0] if respondent_party else None + + if resp: + if resp.employment_type == 'unemployed': + triggered.add('income_imputation') + elif resp.employment_type in ('self_employed', 'underemployed'): + triggered.update(['self_employment_income', 'income_imputation']) + if pet and pet.employment_type == 'self_employed': + triggered.add('self_employment_income') + + # Large income disparity → possible income imputation issue + if pet and resp: + pet_income = pet.effective_monthly_income or 0 + resp_income = resp.effective_monthly_income or 0 + if (pet_income > 0 and resp_income > 0 + and resp.employment_type not in ('unemployed', 'self_employed', 'underemployed')): + ratio = max(pet_income, resp_income) / min(pet_income, resp_income) if ratio > 3.0: triggered.add('income_imputation') - # Emancipation approaching - if case.child_ids: - for child in case.child_ids: - if child.approaching_emancipation: - triggered.add('post_order') - break + # Approaching emancipation → post_order concern + for child in case.child_ids: + if child.approaching_emancipation: + triggered.add('post_order') + break return triggered @@ -187,20 +170,17 @@ class FlAiEngine(models.AbstractModel): def _match_caselaw(self, triggered_tags): """ - Search the fl.caselaw library for cases matching the triggered issue tags. - Prioritizes 3rd DCA cases (Miami-Dade's primary appellate court), - then FL Supreme Court, then other DCAs. + Search the fl.caselaw library for cases matching triggered issue tags. + Prioritizes 3rd DCA (Miami-Dade's appellate court), then FL Supreme Court. Returns up to MAX_CASELAW_IN_PROMPT records. """ if not triggered_tags: - # Return a small set of foundational cases return self.env['fl.caselaw'].search( [('active', '=', True)], order='year desc', limit=4, ) - # Collect matching case IDs per tag, with deduplication matched_ids = set() for tag in triggered_tags: domain = TAG_TO_CASELAW_DOMAINS.get(tag, []) @@ -214,23 +194,20 @@ class FlAiEngine(models.AbstractModel): if not matched_ids: return self.env['fl.caselaw'].browse() - # Fetch and sort: 3rd DCA first, then FL Supreme, then by year desc - cases = self.env['fl.caselaw'].browse(list(matched_ids)) court_priority = { - '3rd_dca': 0, - 'fl_supreme': 1, - '4th_dca': 2, - '2nd_dca': 3, - '1st_dca': 4, - '5th_dca': 5, - '11th_circuit': 6, - 'other': 7, + '3rd_dca': 0, 'fl_supreme': 1, + '4th_dca': 2, '2nd_dca': 3, + '1st_dca': 4, '5th_dca': 5, + '11th_circuit': 6, 'other': 7, } + cases = self.env['fl.caselaw'].browse(list(matched_ids)) sorted_cases = sorted( cases, key=lambda c: (court_priority.get(c.court, 9), -c.year) ) - return self.env['fl.caselaw'].browse([c.id for c in sorted_cases[:MAX_CASELAW_IN_PROMPT]]) + return self.env['fl.caselaw'].browse( + [c.id for c in sorted_cases[:MAX_CASELAW_IN_PROMPT]] + ) # ────────────────────────────────────────────────────────────────────────── # Step 3: Build case context @@ -239,20 +216,25 @@ class FlAiEngine(models.AbstractModel): def _build_case_context(self, case, matched_cases): """ Serialize the case into a JSON-serializable dict for the prompt. - Keeps PII minimal — uses role names, not actual SSNs etc. + Keeps PII minimal — uses role names, not SSNs etc. """ + petitioner_party = case.party_ids.filtered(lambda p: p.role == 'petitioner') + respondent_party = case.party_ids.filtered(lambda p: p.role == 'respondent') + pet = petitioner_party[0] if petitioner_party else None + resp = respondent_party[0] if respondent_party else None + context = { 'case_type': case.case_type, 'stage': case.stage_id.name if case.stage_id else 'unknown', 'filing_date': str(case.filing_date) if case.filing_date else None, 'service_date': str(case.service_date) if case.service_date else None, 'petitioner': { - 'employment': case.petitioner_id.employment_status if case.petitioner_id else 'unknown', - 'monthly_net_income': case.petitioner_net_income or 0, + 'employment': pet.employment_type if pet else 'unknown', + 'monthly_net_income': pet.effective_monthly_income if pet else 0, }, 'respondent': { - 'employment': case.respondent_id.employment_status if case.respondent_id else 'unknown', - 'monthly_net_income': case.respondent_net_income or 0, + 'employment': resp.employment_type if resp else 'unknown', + 'monthly_net_income': resp.effective_monthly_income if resp else 0, 'answered': case.respondent_answered, 'has_counsel': case.respondent_has_counsel, }, @@ -268,21 +250,26 @@ class FlAiEngine(models.AbstractModel): { 'age': c.age, 'approaching_emancipation': c.approaching_emancipation, - 'days_until_emancipation': c.days_until_emancipation if c.approaching_emancipation else None, + 'days_until_emancipation': ( + c.days_until_emancipation if c.approaching_emancipation else None + ), } for c in (case.child_ids or []) if not c.emancipated ], 'timesharing': { - 'petitioner_overnights': case.petitioner_overnights_year or 0, - 'respondent_overnights': case.respondent_overnights_year or 0, - 'substantial_timesharing': case.substantial_timesharing, + 'petitioner_overnights': case.petitioner_overnights or 0, + 'respondent_overnights': case.respondent_overnights or 0, + 'substantial_timesharing': case.substantial_timesharing_applies, }, 'residency_met': case.residency_requirement_met, 'domestic_violence': case.domestic_violence_flag, 'fee_waiver_eligible': case.fee_waiver_eligible, 'parenting_class_required': case.parenting_class_required, - 'parenting_class_completed': case.parenting_class_completed, + 'parenting_class_completed': ( + case.petitioner_parenting_class_done + and case.respondent_parenting_class_done + ), 'issue_tags': list(case.issue_tag_ids.mapped('name')), 'caselaw': [ { @@ -300,10 +287,7 @@ class FlAiEngine(models.AbstractModel): # ────────────────────────────────────────────────────────────────────────── def _assess_complexity(self, case, triggered_tags): - """ - Simple heuristic complexity scorer. - Returns 'simple', 'moderate', or 'complex'. - """ + """Simple heuristic complexity scorer. Returns 'simple', 'moderate', or 'complex'.""" score = 0 if case.domestic_violence_flag: score += 3 @@ -325,15 +309,34 @@ class FlAiEngine(models.AbstractModel): else: return 'complex' + def _fallback_complexity(self, case): + """ + Rule-based complexity fallback used when Claude API is unavailable. + Returns 'simple', 'moderate', or 'complex'. + """ + score = 0 + if case.domestic_violence_flag: + score += 3 + if case.respondent_has_counsel: + score += 2 + if case.child_ids and len(case.child_ids) > 2: + score += 1 + if score <= 1: + return 'simple' + elif score <= 4: + return 'moderate' + else: + return 'complex' + # ────────────────────────────────────────────────────────────────────────── - # Step 5: Build Ollama prompt + # Step 5: Build prompt # ────────────────────────────────────────────────────────────────────────── def _build_prompt(self, context, complexity): """ - Construct the system + user prompt for Ollama. - Returns a string. - The LLM is instructed to respond with a JSON object only. + Construct the prompt for Claude. + Returns a string (user message content). + The model is instructed to respond with a JSON object only. """ context_json = json.dumps(context, indent=2) @@ -364,7 +367,7 @@ TASK: Analyze this case and return a JSON object with exactly these fields: "petitioner_arguments": ["argument 1", "argument 2", "argument 3"], "respondent_counterarguments": ["counterargument 1", "counterargument 2"], "procedural_risks": ["risk 1", "risk 2"], - "attorney_referral_flag": {"attorney_trigger" if attorney_trigger else "false"}, + "attorney_referral_flag": {"true" if attorney_trigger else "false"}, "attorney_referral_reason": "reason if flag is true, else null", "confidence_level": "high|medium|low", "case_complexity": "{complexity}", @@ -376,55 +379,58 @@ Respond with the JSON object only. No other text.""" return prompt # ────────────────────────────────────────────────────────────────────────── - # Step 6: Call Ollama + # Step 6: Call Claude API # ────────────────────────────────────────────────────────────────────────── - def _call_ollama(self, prompt): + def _call_claude(self, prompt): """ - Call Ollama API (llama3.1) and return parsed JSON dict. - Raises on network error or JSON parse failure. + Call Claude API and return parsed JSON dict. + Raises RuntimeError on connection error or JSON parse failure. + API key read from ir.config_parameter key 'fl_ai.claude_api_key'. """ try: - import requests + import anthropic except ImportError: raise RuntimeError( - 'requests library not available. ' - 'Install with: pip install requests' + 'anthropic library not installed. Run: pip install anthropic' ) - _logger.info("FL AI Engine: calling Ollama at %s (model=%s)", OLLAMA_URL, OLLAMA_MODEL) + api_key = self.env['ir.config_parameter'].sudo().get_param('fl_ai.claude_api_key') + if not api_key: + raise RuntimeError( + 'Claude API key not configured. ' + 'Set fl_ai.claude_api_key in Settings → Technical → System Parameters.' + ) - response = requests.post( - OLLAMA_URL, - json={ - 'model': OLLAMA_MODEL, - 'prompt': prompt, - 'stream': False, - 'options': { - 'temperature': 0.1, - 'top_p': 0.9, - 'num_predict': 2000, - }, - }, - timeout=180, - ) - response.raise_for_status() + _logger.info("FL AI Engine: calling Claude API (model=%s)", CLAUDE_MODEL) - raw = response.json().get('response', '{}').strip() + client = anthropic.Anthropic(api_key=api_key) + + try: + message = client.messages.create( + model=CLAUDE_MODEL, + max_tokens=2048, + messages=[{'role': 'user', 'content': prompt}], + ) + except anthropic.RateLimitError as exc: + raise RuntimeError(f'Claude API rate limit exceeded: {exc}') from exc + except anthropic.APIConnectionError as exc: + raise RuntimeError(f'Claude API connection error: {exc}') from exc + except anthropic.APIError as exc: + raise RuntimeError(f'Claude API error: {exc}') from exc + + raw = message.content[0].text.strip() _logger.debug("FL AI Engine raw response (first 200 chars): %s", raw[:200]) # Strip markdown code fences if present if raw.startswith('```'): parts = raw.split('```') - if len(parts) >= 3: - raw = parts[1] - elif len(parts) == 2: - raw = parts[1] + raw = parts[1] if len(parts) >= 2 else raw if raw.lower().startswith('json'): raw = raw[4:] raw = raw.strip() - # Extract first JSON object if extra text leaked through + # Extract first JSON object if extra prose leaked through if raw and raw[0] == '{': brace_depth = 0 end_idx = 0 @@ -442,22 +448,18 @@ Respond with the JSON object only. No other text.""" return json.loads(raw) except json.JSONDecodeError as exc: _logger.error("FL AI Engine: JSON parse error: %s\nRaw: %s", exc, raw[:500]) - raise RuntimeError(f"Ollama returned invalid JSON: {exc}") from exc + raise RuntimeError(f"Claude returned invalid JSON: {exc}") from exc # ────────────────────────────────────────────────────────────────────────── # Step 7: Store analysis # ────────────────────────────────────────────────────────────────────────── def _store_analysis(self, analysis, result, matched_cases, complexity): - """ - Write parsed Ollama result into the fl.analysis record. - """ - # Normalize boolean field from Ollama (may come as string "true") + """Write parsed Claude result into the fl.analysis record.""" attorney_flag = result.get('attorney_referral_flag', False) if isinstance(attorney_flag, str): attorney_flag = attorney_flag.lower() in ('true', '1', 'yes') - # Serialize list fields to JSON strings for Text fields petitioner_args = result.get('petitioner_arguments', []) respondent_counter = result.get('respondent_counterarguments', []) procedural_risks = result.get('procedural_risks', []) @@ -481,14 +483,13 @@ Respond with the JSON object only. No other text.""" analysis.write(vals) - # If attorney referral flagged, post urgent chatter message on the case if attorney_flag and analysis.case_id: analysis.case_id.message_post( body=( "⚠ AI ANALYSIS — ATTORNEY REFERRAL RECOMMENDED
" f"{result.get('attorney_referral_reason', 'Case complexity warrants legal counsel.')}
" "FL Volunteer Lawyers Project: flvlp.org | " - "Three-Day Rule: 3-1-1 Legal Info: flcourts.gov" + "3-1-1 Legal Info: flcourts.gov" ), message_type='comment', subtype_xmlid='mail.mt_comment', diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index 0b214ce..dbc782e 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -2,6 +2,20 @@ from odoo import _, api, fields, models from dateutil.relativedelta import relativedelta +class FlCaseStage(models.Model): + _name = 'fl.case.stage' + _description = 'Family Law Case Stage' + _order = 'sequence, id' + + name = fields.Char(string='Stage Name', required=True, translate=True) + sequence = fields.Integer(default=10) + fold = fields.Boolean( + string='Folded in Kanban', + help='Folded stages appear collapsed on the Kanban board' + ) + description = fields.Text(string='Stage Description') + + class FlCase(models.Model): _name = 'fl.case' _description = 'Florida Family Law Case' @@ -61,19 +75,15 @@ class FlCase(models.Model): # STAGE / STATUS # ══════════════════════════════════════════════════════════════════════ - stage = fields.Selection([ - ('intake', 'Intake & Qualification'), - ('preparation', 'Document Preparation'), - ('filed', 'Filed — Awaiting Service'), - ('service_complete', 'Service Complete'), - ('discovery', 'Discovery'), - ('deposition', 'Deposition Stage'), - ('mediation', 'Mediation'), - ('hearing_scheduled', 'Hearing Scheduled'), - ('order_entered', 'Order Entered'), - ('closed', 'Closed'), - ('referred_out', 'Referred to Attorney'), - ], string='Stage', default='intake', tracking=True) + stage_id = fields.Many2one( + 'fl.case.stage', + string='Stage', + group_expand='_read_group_stage_ids', + default=lambda self: self.env['fl.case.stage'].search( + [], order='sequence asc', limit=1 + ), + tracking=True, + ) active = fields.Boolean(default=True) # ══════════════════════════════════════════════════════════════════════ @@ -460,6 +470,10 @@ class FlCase(models.Model): # COMPUTED METHODS # ══════════════════════════════════════════════════════════════════════ + @api.model + def _read_group_stage_ids(self, stages, domain, *args): + return stages.search([]) + @api.depends('respondent_attorney_id') def _compute_respondent_has_counsel(self): for rec in self: @@ -1006,14 +1020,10 @@ class FlCase(models.Model): ) def trigger_ai_analysis(self): - """ - Trigger AI case analysis via Ollama (fl.ai.engine). - Phase 5 — full implementation. - """ self.ensure_one() engine = self.env['fl.ai.engine'] self.message_post( - body='🤖 AI analysis started. This may take up to 3 minutes...', + body='🤖 AI analysis started. This may take up to 30 seconds...', subtype_xmlid='mail.mt_note', ) analysis = engine.analyze_case(self.id) diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv index 4a89ecd..a4dd4f5 100644 --- a/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw/security/ir.model.access.csv @@ -1,4 +1,8 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +# ── fl.case.stage ───────────────────────────────────────────────────────────── +access_fl_case_stage_admin,fl.case.stage admin,model_fl_case_stage,group_admin,1,1,1,1 +access_fl_case_stage_paralegal,fl.case.stage paralegal,model_fl_case_stage,group_paralegal,1,0,0,0 +access_fl_case_stage_petitioner,fl.case.stage petitioner,model_fl_case_stage,group_portal_petitioner,1,0,0,0 # ── fl.case ────────────────────────────────────────────────────────────────── access_fl_case_admin,fl.case admin,model_fl_case,group_admin,1,1,1,1 access_fl_case_paralegal,fl.case paralegal,model_fl_case,group_paralegal,1,1,1,0 diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 3fa9561..0bfb04a 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -11,8 +11,7 @@
- +