Migrate AI engine to Claude API; convert stage field to Many2one Kanban model
- Replace fl.case.stage Selection field with Many2one → fl.case.stage model, enabling Kanban grouping and dynamic stage management - Add FlCaseStage model (sequence, fold, description) and fl_stage_data.xml with all 11 procedural stages seeded with noupdate=1 - Migrate fl_ai_engine.py from Ollama/llama3.1 to Claude API (claude-sonnet-4-20250514); key from ir.config_parameter fl_ai.claude_api_key - Fix stale field references in _rule_based_tagging and _build_case_context: employment/income now read from party_ids, timesharing fields corrected - Add _fallback_complexity() for graceful degradation when API unavailable - Add Kanban view to fl_case_views.xml; update action view_mode to kanban,tree,form - Add fl.case.stage ACL entries to ir.model.access.csv Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
72
activeblue_familylaw/data/fl_stage_data.xml
Normal file
72
activeblue_familylaw/data/fl_stage_data.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="fl_stage_intake" model="fl.case.stage">
|
||||
<field name="name">Intake & Qualification</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_preparation" model="fl.case.stage">
|
||||
<field name="name">Document Preparation</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_filed" model="fl.case.stage">
|
||||
<field name="name">Filed — Awaiting Service</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_service_complete" model="fl.case.stage">
|
||||
<field name="name">Service Complete</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_discovery" model="fl.case.stage">
|
||||
<field name="name">Discovery</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_deposition" model="fl.case.stage">
|
||||
<field name="name">Deposition Stage</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_mediation" model="fl.case.stage">
|
||||
<field name="name">Mediation</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_hearing_scheduled" model="fl.case.stage">
|
||||
<field name="name">Hearing Scheduled</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_order_entered" model="fl.case.stage">
|
||||
<field name="name">Order Entered</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="fold">False</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_closed" model="fl.case.stage">
|
||||
<field name="name">Closed</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="fold">True</field>
|
||||
</record>
|
||||
|
||||
<record id="fl_stage_referred_out" model="fl.case.stage">
|
||||
<field name="name">Referred to Attorney</field>
|
||||
<field name="sequence">110</field>
|
||||
<field name="fold">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -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=(
|
||||
"<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>"
|
||||
"3-1-1 Legal Info: <a href='https://www.flcourts.gov'>flcourts.gov</a>"
|
||||
),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -11,8 +11,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Family Law Case">
|
||||
<header>
|
||||
<field name="stage" widget="statusbar"
|
||||
statusbar_visible="intake,preparation,filed,service_complete,discovery,mediation,hearing_scheduled,order_entered,closed"/>
|
||||
<field name="stage_id" widget="statusbar" options="{'clickable': '1'}"/>
|
||||
<button name="action_run_ai_analysis" string="Run AI Analysis"
|
||||
type="object" class="oe_highlight"
|
||||
groups="activeblue_familylaw.group_admin,activeblue_familylaw.group_paralegal"/>
|
||||
@@ -301,7 +300,7 @@
|
||||
<field name="court_case_number"/>
|
||||
<field name="petitioner_id"/>
|
||||
<field name="respondent_id"/>
|
||||
<field name="stage"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="filing_date"/>
|
||||
<field name="next_deadline"/>
|
||||
<field name="next_deadline_label" string="Next Deadline"/>
|
||||
@@ -330,11 +329,11 @@
|
||||
<filter string="Active Cases" name="active"
|
||||
domain="[('active', '=', True)]"/>
|
||||
<filter string="Intake" name="stage_intake"
|
||||
domain="[('stage', '=', 'intake')]"/>
|
||||
domain="[('stage_id.name', '=', 'Intake & Qualification')]"/>
|
||||
<filter string="Filed" name="stage_filed"
|
||||
domain="[('stage', '=', 'filed')]"/>
|
||||
domain="[('stage_id.name', '=', 'Filed — Awaiting Service')]"/>
|
||||
<filter string="Discovery" name="stage_discovery"
|
||||
domain="[('stage', '=', 'discovery')]"/>
|
||||
domain="[('stage_id.name', '=', 'Discovery')]"/>
|
||||
<separator/>
|
||||
<filter string="Modification Cases" name="type_modification"
|
||||
domain="[('case_type', '=', 'modification')]"/>
|
||||
@@ -351,7 +350,7 @@
|
||||
domain="[('threshold_met', '=', True)]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Stage" name="group_stage" context="{'group_by': 'stage'}"/>
|
||||
<filter string="Stage" name="group_stage" context="{'group_by': 'stage_id'}"/>
|
||||
<filter string="Case Type" name="group_type" context="{'group_by': 'case_type'}"/>
|
||||
<filter string="Filing Date" name="group_date" context="{'group_by': 'filing_date:month'}"/>
|
||||
</group>
|
||||
@@ -359,13 +358,76 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
KANBAN VIEW
|
||||
══════════════════════════════════════════════════════ -->
|
||||
<record id="view_fl_case_kanban" model="ir.ui.view">
|
||||
<field name="name">fl.case.kanban</field>
|
||||
<field name="model">fl.case</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="stage_id" on_create="quick_create"
|
||||
quick_create_view="activeblue_familylaw.view_fl_case_form">
|
||||
<field name="name"/>
|
||||
<field name="case_type"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="next_deadline"/>
|
||||
<field name="next_deadline_label"/>
|
||||
<field name="attorney_referral_flag"/>
|
||||
<field name="domestic_violence_flag"/>
|
||||
<field name="overdue_deadline_count"/>
|
||||
<field name="petitioner_id"/>
|
||||
<templates>
|
||||
<t t-name="kanban-card">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_record_top">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
<span class="o_kanban_record_subtitle">
|
||||
<field name="petitioner_id"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<span class="badge rounded-pill text-bg-secondary">
|
||||
<field name="case_type"/>
|
||||
</span>
|
||||
<t t-if="record.attorney_referral_flag.raw_value">
|
||||
<span class="badge rounded-pill text-bg-danger ms-1">Atty Referral</span>
|
||||
</t>
|
||||
<t t-if="record.domestic_violence_flag.raw_value">
|
||||
<span class="badge rounded-pill text-bg-warning ms-1">DV</span>
|
||||
</t>
|
||||
<t t-if="record.overdue_deadline_count.raw_value > 0">
|
||||
<span class="badge rounded-pill text-bg-danger ms-1">
|
||||
<t t-esc="record.overdue_deadline_count.raw_value"/> Overdue
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_kanban_record_bottom" t-if="record.next_deadline.raw_value">
|
||||
<div class="oe_kanban_bottom_left text-muted small">
|
||||
<i class="fa fa-calendar-o me-1"/>
|
||||
<field name="next_deadline"/>
|
||||
<t t-if="record.next_deadline_label.raw_value">
|
||||
— <field name="next_deadline_label"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
ACTIONS
|
||||
══════════════════════════════════════════════════════ -->
|
||||
<record id="action_fl_case_list" model="ir.actions.act_window">
|
||||
<field name="name">Family Law Cases</field>
|
||||
<field name="res_model">fl.case</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">kanban,tree,form</field>
|
||||
<field name="search_view_id" ref="view_fl_case_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
|
||||
Reference in New Issue
Block a user