Files
famlaw/activeblue_familylaw/models/fl_ai_engine.py
Carlos Garcia 7ce691a394 Phase 5: Full AI engine + caselaw seed data (23 FL cases)
fl_ai_engine.py: Complete Ollama integration with 7-step pipeline:
rule-based issue tagging, caselaw matching (3rd DCA-prioritized),
case context serialization, prompt construction, Ollama HTTP call
(llama3.1 at 192.168.2.10:11434), JSON parsing with fence-strip,
and fl.analysis persistence with attorney referral chatter alert.

fl_caselaw_data.xml: 23 seeded Florida cases covering modification
threshold (Daly, Regan, Pimm, El Kohen, Rolfe), income imputation
(Barner, Sitterson), self-employment (Smith, Young), timesharing
(Freid, Kennedy, Boykin), domestic violence (Conlin, Kahle),
default judgment (Lindsey, North), residency (Fults), parenting
class (Maddox), fee waiver (Abdool, Kielbania), disclosure,
withholding, above-schedule, and discovery sanctions.

fl_case.py: trigger_ai_analysis() wired to engine.analyze_case();
returns form popup of fl.analysis result.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 23:39:41 -04:00

501 lines
23 KiB
Python

import json
import logging
from odoo import models
_logger = logging.getLogger(__name__)
OLLAMA_URL = 'http://192.168.2.10:11434/api/generate'
OLLAMA_MODEL = 'llama3.1'
# ─────────────────────────────────────────────────────────────────────────────
# Rule-based issue tag weights
# Maps (field_name, value_or_True) → list of issue tag XML ids
# ─────────────────────────────────────────────────────────────────────────────
ISSUE_RULES = [
# Modification threshold
('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
('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')],
'income_imputation': [('issue_tag_ids.name', '=', 'income_imputation')],
'self_employment_income': [('issue_tag_ids.name', '=', 'self_employment_income')],
'timesharing_deviation': [('issue_tag_ids.name', '=', 'timesharing_deviation')],
'domestic_violence': [('issue_tag_ids.name', '=', 'domestic_violence')],
'fee_waiver': [('issue_tag_ids.name', '=', 'fee_waiver')],
'default_judgment': [('issue_tag_ids.name', '=', 'default_judgment')],
'residency': [('issue_tag_ids.name', '=', 'residency')],
'parenting_class': [('issue_tag_ids.name', '=', 'parenting_class')],
'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.
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
6. _store_analysis — persist fl.analysis record with results
AbstractModel — not stored in the database.
"""
_name = 'fl.ai.engine'
_description = 'Family Law AI Analysis Engine (Ollama)'
# ──────────────────────────────────────────────────────────────────────────
# Public entry point
# ──────────────────────────────────────────────────────────────────────────
def analyze_case(self, case_id):
"""
Full Phase 5 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")
analysis_vals = {
'case_id': case.id,
'state': 'pending',
'model_used': OLLAMA_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', '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
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)
analysis.write({
'state': 'failed',
'error_message': str(exc),
'plain_english_summary': (
"AI analysis could not be completed at this time. "
"Please try again later or contact support."
),
'plain_english_summary_es': (
"El análisis de IA no pudo completarse en este momento. "
"Por favor intente más tarde o contacte soporte."
),
})
return analysis
# ──────────────────────────────────────────────────────────────────────────
# Step 1: Rule-based tagging
# ──────────────────────────────────────────────────────────────────────────
def _rule_based_tagging(self, case):
"""
Apply deterministic rules to identify legal issues present 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
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)
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
return triggered
# ──────────────────────────────────────────────────────────────────────────
# Step 2: Match caselaw
# ──────────────────────────────────────────────────────────────────────────
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.
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, [])
if domain:
recs = self.env['fl.caselaw'].search(
[('active', '=', True)] + domain,
limit=6,
)
matched_ids.update(recs.ids)
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,
}
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]])
# ──────────────────────────────────────────────────────────────────────────
# Step 3: Build case context
# ──────────────────────────────────────────────────────────────────────────
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.
"""
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,
},
'respondent': {
'employment': case.respondent_id.employment_status if case.respondent_id else 'unknown',
'monthly_net_income': case.respondent_net_income or 0,
'answered': case.respondent_answered,
'has_counsel': case.respondent_has_counsel,
},
'support': {
'current_order_amount': case.current_order_amount or 0,
'calculated_support': case.calculated_support or 0,
'support_difference': case.support_difference or 0,
'support_difference_pct': round(case.support_difference_pct or 0, 1),
'threshold_met': case.threshold_met,
'substantial_change_basis': case.substantial_change_basis or None,
},
'children': [
{
'age': c.age,
'approaching_emancipation': c.approaching_emancipation,
'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,
},
'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,
'issue_tags': list(case.issue_tag_ids.mapped('name')),
'caselaw': [
{
'citation': cl.citation,
'holding': (cl.holding or '')[:300],
'favorable_to': cl.favorable_to,
}
for cl in matched_cases
],
}
return context
# ──────────────────────────────────────────────────────────────────────────
# Step 4: Complexity assessment
# ──────────────────────────────────────────────────────────────────────────
def _assess_complexity(self, case, triggered_tags):
"""
Simple heuristic complexity scorer.
Returns 'simple', 'moderate', or 'complex'.
"""
score = 0
if case.domestic_violence_flag:
score += 3
if case.respondent_has_counsel:
score += 2
if 'income_imputation' in triggered_tags:
score += 2
if 'self_employment_income' in triggered_tags:
score += 2
if case.child_ids and len(case.child_ids) > 2:
score += 1
if triggered_tags:
score += len(triggered_tags)
if score <= 3:
return 'simple'
elif score <= 7:
return 'moderate'
else:
return 'complex'
# ──────────────────────────────────────────────────────────────────────────
# Step 5: Build Ollama 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.
"""
context_json = json.dumps(context, indent=2)
attorney_trigger = (
context.get('domestic_violence') or
context.get('respondent', {}).get('has_counsel') or
complexity == 'complex'
)
prompt = f"""You are an AI legal assistant for a Florida family law case management system. \
Your role is to help pro se (self-represented) litigants in Miami-Dade County understand their case. \
You are NOT providing legal advice — you are explaining the legal framework and procedure.
IMPORTANT RULES:
- Always recommend an attorney when the case involves domestic violence, opposing counsel, or high complexity.
- Florida uses the FL 61.30 income-shares model for child support.
- Modification threshold: 15% AND $50 difference required (FL 61.30(1)(b)).
- Timesharing credit applies when either parent has >73 overnights/year (FL 61.30(11)(b)).
- All output must be JSON only — no markdown, no prose outside the JSON object.
CASE DATA:
{context_json}
TASK: Analyze this case and return a JSON object with exactly these fields:
{{
"plain_english_summary": "3-5 sentence plain English explanation of the case situation and key legal issues — no jargon",
"plain_english_summary_es": "Same 3-5 sentences in Spanish",
"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_reason": "reason if flag is true, else null",
"confidence_level": "high|medium|low",
"case_complexity": "{complexity}",
"next_steps": ["step 1", "step 2", "step 3"]
}}
Respond with the JSON object only. No other text."""
return prompt
# ──────────────────────────────────────────────────────────────────────────
# Step 6: Call Ollama
# ──────────────────────────────────────────────────────────────────────────
def _call_ollama(self, prompt):
"""
Call Ollama API (llama3.1) and return parsed JSON dict.
Raises on network error or JSON parse failure.
"""
try:
import requests
except ImportError:
raise RuntimeError(
'requests library not available. '
'Install with: pip install requests'
)
_logger.info("FL AI Engine: calling Ollama at %s (model=%s)", OLLAMA_URL, OLLAMA_MODEL)
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()
raw = response.json().get('response', '{}').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]
if raw.lower().startswith('json'):
raw = raw[4:]
raw = raw.strip()
# Extract first JSON object if extra text leaked through
if raw and raw[0] == '{':
brace_depth = 0
end_idx = 0
for i, ch in enumerate(raw):
if ch == '{':
brace_depth += 1
elif ch == '}':
brace_depth -= 1
if brace_depth == 0:
end_idx = i + 1
break
raw = raw[:end_idx]
try:
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
# ──────────────────────────────────────────────────────────────────────────
# 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")
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', [])
vals = {
'state': 'complete',
'plain_english_summary': result.get('plain_english_summary', ''),
'plain_english_summary_es': result.get('plain_english_summary_es', ''),
'attorney_referral_flag': bool(attorney_flag),
'attorney_referral_reason': result.get('attorney_referral_reason') or '',
'confidence_level': result.get('confidence_level', 'medium'),
'case_complexity': result.get('case_complexity', complexity),
'petitioner_arguments': json.dumps(petitioner_args, ensure_ascii=False, indent=2),
'respondent_counterarguments': json.dumps(respondent_counter, ensure_ascii=False, indent=2),
'procedural_risks': json.dumps(procedural_risks, ensure_ascii=False, indent=2),
'raw_response': json.dumps(result, ensure_ascii=False, indent=2),
}
if matched_cases:
vals['matched_caselaw_ids'] = [(6, 0, matched_cases.ids)]
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=(
"<strong>⚠ AI ANALYSIS — ATTORNEY REFERRAL RECOMMENDED</strong><br/>"
f"{result.get('attorney_referral_reason', 'Case complexity warrants legal counsel.')}<br/>"
"FL Volunteer Lawyers Project: <a href='https://www.flvlp.org'>flvlp.org</a> | "
"Three-Day Rule: 3-1-1 Legal Info: <a href='https://www.flcourts.gov'>flcourts.gov</a>"
),
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
_logger.info(
"FL AI Engine: analysis %s complete (complexity=%s, attorney_referral=%s)",
analysis.id, complexity, attorney_flag
)