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>
501 lines
23 KiB
Python
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
|
|
)
|