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:
2026-05-28 17:45:26 +00:00
parent c937282091
commit b8ab8494c7
6 changed files with 313 additions and 163 deletions

View File

@@ -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',

View 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 &amp; 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>

View File

@@ -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_ollamaHTTP call to Ollama with error handling
4. _build_prompt — compose the Claude prompt
5. _call_claudeClaude 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',

View File

@@ -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)

View File

@@ -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
1 id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
2 # ── fl.case.stage ─────────────────────────────────────────────────────────────
3 access_fl_case_stage_admin,fl.case.stage admin,model_fl_case_stage,group_admin,1,1,1,1
4 access_fl_case_stage_paralegal,fl.case.stage paralegal,model_fl_case_stage,group_paralegal,1,0,0,0
5 access_fl_case_stage_petitioner,fl.case.stage petitioner,model_fl_case_stage,group_portal_petitioner,1,0,0,0
6 # ── fl.case ──────────────────────────────────────────────────────────────────
7 access_fl_case_admin,fl.case admin,model_fl_case,group_admin,1,1,1,1
8 access_fl_case_paralegal,fl.case paralegal,model_fl_case,group_paralegal,1,1,1,0

View File

@@ -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 &amp; 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">