Step 6: Claude client + ai.task ledger + drafting agent + citation ledger (Tier 3)
TIER 3 scaffolding — structure built, legal CONTENT born unapproved/unverified and NOT authoritative until attorney validation. External calls mocked in all tests. familylaw.ai.client (AbstractModel service): - _route_model() pluggable provider seam (Ollama = future config flip) - BLOCKED_TASK_TYPES (trust_billing/reconciliation/iota) RAISE before any call — trust/billing never reaches a model (Bar Rule 5-1.1, IOTA) - per-task token ceiling caps max_tokens; config via ir.config_parameter - _call_provider isolates the real HTTPS call (the single mock point) - generate() routes, calls, and logs an ai.task; returns (result, task) familylaw.ai.task — the audit/economics ledger: model_used, prompt/completion/total tokens, estimated cost_usd (per-model price table, flagged estimate), latency_ms, request/response summaries, error, links to case/proceeding/document. familylaw.citation — born 'unverified' (Gate 2 verification + filing block come in Step 7); is_verified computed; linked to document + source ai.task. familylaw.document.ai_assemble(): single-shot drafting agent — doc born ai_draft (source ai), proposed citations born unverified, task linked. NOT auto-approved. Plus familylaw.ai.draft.wizard for the UI walkthrough; Citations tab on document; AI Draft button on proceeding; AI Tasks + Citations menus. Tests (familylaw_step6, mocked _call_provider): doc born ai_draft, ai.task logged with model+tokens+cost+latency, citations born unverified, token ceiling caps max_tokens, trust/billing blocked before any provider call, provider error marks task failed, deterministic cost estimate, missing-key raises, AI draft cannot be filed even by an attorney (Gate 1 intact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,17 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
"name": "Active Blue Family Law",
|
"name": "Active Blue Family Law",
|
||||||
"version": "18.0.5.0.0",
|
"version": "18.0.6.0.0",
|
||||||
"category": "Services/Legal",
|
"category": "Services/Legal",
|
||||||
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
|
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
|
||||||
"description": """
|
"description": """
|
||||||
Active Blue Family Law
|
Active Blue Family Law
|
||||||
======================
|
======================
|
||||||
Case-management platform for a Florida family-law practice, built in verifiable
|
Case-management platform for a Florida family-law practice, built in verifiable
|
||||||
steps. Step 1: case spine. Step 2: parties/children/issues/proceedings, conflict
|
steps. 1: case spine. 2: parties/children/issues/proceedings, conflict screening,
|
||||||
screening, intake. Step 3: documents + review gate. Step 4: deadline engine
|
intake. 3: documents + review gate. 4: deadlines. 5: mandatory disclosure.
|
||||||
(per-proceeding clocks, weekend roll, overdue cron, calendar mirror).
|
6: Claude client + ai.task ledger + drafting agent + citation ledger (Tier 3 —
|
||||||
|
legal content born as unapproved/unverified drafts; external calls mocked in tests).
|
||||||
|
|
||||||
Each step adds one vertical, independently testable slice. See BUILD_PLAN.md.
|
Each step adds one vertical, independently testable slice. See BUILD_PLAN.md.
|
||||||
""",
|
""",
|
||||||
@@ -29,10 +30,11 @@ Each step adds one vertical, independently testable slice. See BUILD_PLAN.md.
|
|||||||
"views/familylaw_party_views.xml",
|
"views/familylaw_party_views.xml",
|
||||||
"views/familylaw_child_views.xml",
|
"views/familylaw_child_views.xml",
|
||||||
"views/familylaw_issue_views.xml",
|
"views/familylaw_issue_views.xml",
|
||||||
"views/familylaw_proceeding_views.xml",
|
|
||||||
"views/familylaw_document_views.xml",
|
"views/familylaw_document_views.xml",
|
||||||
"views/familylaw_deadline_views.xml",
|
"views/familylaw_deadline_views.xml",
|
||||||
"views/familylaw_disclosure_views.xml",
|
"views/familylaw_disclosure_views.xml",
|
||||||
|
"views/familylaw_ai_views.xml",
|
||||||
|
"views/familylaw_proceeding_views.xml",
|
||||||
"views/familylaw_intake_views.xml",
|
"views/familylaw_intake_views.xml",
|
||||||
"views/familylaw_case_views.xml",
|
"views/familylaw_case_views.xml",
|
||||||
"views/familylaw_menus.xml",
|
"views/familylaw_menus.xml",
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ from . import familylaw_intake
|
|||||||
from . import familylaw_document
|
from . import familylaw_document
|
||||||
from . import familylaw_deadline
|
from . import familylaw_deadline
|
||||||
from . import familylaw_disclosure
|
from . import familylaw_disclosure
|
||||||
|
from . import familylaw_ai
|
||||||
|
from . import familylaw_citation
|
||||||
|
from . import familylaw_ai_wizard
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 6 — Claude client + ai.task ledger (single-shot).
|
||||||
|
|
||||||
|
This is a TIER 3 capability. The STRUCTURE is built here; the legal CONTENT it
|
||||||
|
produces is born as an unapproved draft and is NOT authoritative until a licensed
|
||||||
|
attorney validates and approves it (Gate 1, Step 3). Nothing here decides law.
|
||||||
|
|
||||||
|
Two pieces:
|
||||||
|
* familylaw.ai.client — an AbstractModel service wrapping the outbound HTTPS call
|
||||||
|
to api.anthropic.com/v1/messages. The provider is pluggable via _route_model()
|
||||||
|
(Ollama is a future config flip, not wired). Trust/billing reconciliation NEVER
|
||||||
|
reaches a model — the router raises first (IOTA, Bar Rule 5-1.1: human-only).
|
||||||
|
* familylaw.ai.task — the audit ledger: model used, token counts, estimated cost,
|
||||||
|
and latency are recorded on EVERY call so the economics are measurable and capped.
|
||||||
|
|
||||||
|
External calls are ALWAYS mocked in tests (patch _call_provider). The real network
|
||||||
|
call lives only in _call_provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ir.config_parameter keys
|
||||||
|
PARAM_API_KEY = "familylaw.anthropic_api_key"
|
||||||
|
PARAM_MODEL = "familylaw.model"
|
||||||
|
PARAM_TOKEN_CEILING = "familylaw.token_ceiling"
|
||||||
|
PARAM_ENDPOINT = "familylaw.anthropic_endpoint"
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "claude-sonnet-4-6"
|
||||||
|
DEFAULT_ENDPOINT = "https://api.anthropic.com/v1/messages"
|
||||||
|
DEFAULT_TOKEN_CEILING = 4096
|
||||||
|
ANTHROPIC_VERSION = "2023-06-01"
|
||||||
|
|
||||||
|
# Task types that must NEVER reach any model (human-only, Bar Rule 5-1.1 / IOTA).
|
||||||
|
BLOCKED_TASK_TYPES = {"trust_billing", "trust_reconciliation", "iota"}
|
||||||
|
|
||||||
|
# Estimated prices, USD per 1M tokens. ESTIMATE ONLY — verify current pricing.
|
||||||
|
MODEL_PRICES = {
|
||||||
|
"claude-opus-4-8": {"in": 5.0, "out": 25.0},
|
||||||
|
"claude-sonnet-4-6": {"in": 3.0, "out": 15.0},
|
||||||
|
"claude-haiku-4-5": {"in": 1.0, "out": 5.0},
|
||||||
|
}
|
||||||
|
_DEFAULT_PRICE = {"in": 3.0, "out": 15.0}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawAITask(models.Model):
|
||||||
|
_name = "familylaw.ai.task"
|
||||||
|
_description = "AI Task Ledger"
|
||||||
|
_inherit = ["mail.thread"]
|
||||||
|
_order = "create_date desc"
|
||||||
|
|
||||||
|
name = fields.Char(required=True, default="AI Task")
|
||||||
|
task_type = fields.Char(string="Task Type", required=True, tracking=True)
|
||||||
|
provider = fields.Char(string="Provider", tracking=True)
|
||||||
|
model_used = fields.Char(string="Model", tracking=True,
|
||||||
|
help="The exact model id used, recorded for audit.")
|
||||||
|
state = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
("queued", "Queued"),
|
||||||
|
("running", "Running"),
|
||||||
|
("done", "Done"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
("blocked", "Blocked"),
|
||||||
|
],
|
||||||
|
default="queued",
|
||||||
|
required=True,
|
||||||
|
tracking=True,
|
||||||
|
)
|
||||||
|
case_id = fields.Many2one("familylaw.case", index=True)
|
||||||
|
proceeding_id = fields.Many2one("familylaw.proceeding", index=True)
|
||||||
|
document_id = fields.Many2one("familylaw.document", index=True)
|
||||||
|
|
||||||
|
prompt_tokens = fields.Integer(string="Prompt Tokens")
|
||||||
|
completion_tokens = fields.Integer(string="Completion Tokens")
|
||||||
|
total_tokens = fields.Integer(string="Total Tokens")
|
||||||
|
token_ceiling = fields.Integer(string="Token Ceiling (max_tokens)")
|
||||||
|
cost_usd = fields.Float(string="Est. Cost (USD)", digits=(12, 6))
|
||||||
|
latency_ms = fields.Integer(string="Latency (ms)")
|
||||||
|
|
||||||
|
request_summary = fields.Text(string="Request Summary")
|
||||||
|
response_summary = fields.Text(string="Response Summary")
|
||||||
|
error_message = fields.Text(string="Error")
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawAIClient(models.AbstractModel):
|
||||||
|
_name = "familylaw.ai.client"
|
||||||
|
_description = "Claude API client (single-shot)"
|
||||||
|
|
||||||
|
# --- config -------------------------------------------------------------
|
||||||
|
def _icp(self):
|
||||||
|
return self.env["ir.config_parameter"].sudo()
|
||||||
|
|
||||||
|
def _get_api_key(self):
|
||||||
|
key = self._icp().get_param(PARAM_API_KEY)
|
||||||
|
if not key:
|
||||||
|
raise UserError(
|
||||||
|
_("No Claude API key configured. Set the '%s' system parameter "
|
||||||
|
"(attorney-group readable only).") % PARAM_API_KEY
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _get_model(self):
|
||||||
|
return self._icp().get_param(PARAM_MODEL) or DEFAULT_MODEL
|
||||||
|
|
||||||
|
def _get_endpoint(self):
|
||||||
|
return self._icp().get_param(PARAM_ENDPOINT) or DEFAULT_ENDPOINT
|
||||||
|
|
||||||
|
def _get_token_ceiling(self):
|
||||||
|
raw = self._icp().get_param(PARAM_TOKEN_CEILING)
|
||||||
|
try:
|
||||||
|
return int(raw) if raw else DEFAULT_TOKEN_CEILING
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_TOKEN_CEILING
|
||||||
|
|
||||||
|
# --- routing (pluggable) ------------------------------------------------
|
||||||
|
def _route_model(self, task_type, confidential=False):
|
||||||
|
"""Decide (provider, model). Raises for human-only task types BEFORE any
|
||||||
|
network call. Ollama routing for high-confidentiality work is a future
|
||||||
|
config flip — the seam is here, not wired."""
|
||||||
|
if task_type in BLOCKED_TASK_TYPES:
|
||||||
|
raise UserError(
|
||||||
|
_("Task type '%s' involves trust/billing reconciliation and must "
|
||||||
|
"never be sent to any AI model (Bar Rule 5-1.1, IOTA — human-only).")
|
||||||
|
% task_type
|
||||||
|
)
|
||||||
|
# Future: if confidential and an Ollama model is configured, route local.
|
||||||
|
return "anthropic", self._get_model()
|
||||||
|
|
||||||
|
# --- cost ---------------------------------------------------------------
|
||||||
|
def _estimate_cost(self, model, prompt_tokens, completion_tokens):
|
||||||
|
price = MODEL_PRICES.get(model, _DEFAULT_PRICE)
|
||||||
|
return round(
|
||||||
|
(prompt_tokens / 1_000_000.0) * price["in"]
|
||||||
|
+ (completion_tokens / 1_000_000.0) * price["out"],
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- the actual network call (MOCK THIS IN TESTS) -----------------------
|
||||||
|
def _call_provider(self, provider, model, messages, max_tokens, system=None):
|
||||||
|
"""Perform the outbound HTTPS call. Returns a normalised dict:
|
||||||
|
{"text": str, "usage": {"input_tokens": int, "output_tokens": int},
|
||||||
|
"citations": [ ... optional ... ]}
|
||||||
|
Tests patch this method — they must never hit the network."""
|
||||||
|
import requests # Odoo bundles requests
|
||||||
|
if provider != "anthropic":
|
||||||
|
raise UserError(_("Provider '%s' is not wired yet.") % provider)
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
if system:
|
||||||
|
payload["system"] = system
|
||||||
|
headers = {
|
||||||
|
"x-api-key": self._get_api_key(),
|
||||||
|
"anthropic-version": ANTHROPIC_VERSION,
|
||||||
|
"content-type": "application/json",
|
||||||
|
}
|
||||||
|
resp = requests.post(
|
||||||
|
self._get_endpoint(), headers=headers,
|
||||||
|
data=json.dumps(payload), timeout=120,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
text = "".join(
|
||||||
|
block.get("text", "")
|
||||||
|
for block in data.get("content", [])
|
||||||
|
if block.get("type") == "text"
|
||||||
|
)
|
||||||
|
return {"text": text, "usage": data.get("usage", {}), "citations": []}
|
||||||
|
|
||||||
|
# --- high-level entry point ---------------------------------------------
|
||||||
|
def generate(self, task_type, messages, *, system=None, case=None,
|
||||||
|
proceeding=None, confidential=False, max_tokens=None,
|
||||||
|
name=None):
|
||||||
|
"""Route, call, and log an ai.task. Returns (result_dict, task)."""
|
||||||
|
provider, model = self._route_model(task_type, confidential) # may raise
|
||||||
|
ceiling = self._get_token_ceiling()
|
||||||
|
max_tokens = min(max_tokens or ceiling, ceiling)
|
||||||
|
|
||||||
|
Task = self.env["familylaw.ai.task"]
|
||||||
|
task = Task.create({
|
||||||
|
"name": name or _("AI: %s") % task_type,
|
||||||
|
"task_type": task_type,
|
||||||
|
"provider": provider,
|
||||||
|
"model_used": model,
|
||||||
|
"token_ceiling": max_tokens,
|
||||||
|
"state": "running",
|
||||||
|
"case_id": case.id if case else False,
|
||||||
|
"proceeding_id": proceeding.id if proceeding else False,
|
||||||
|
"request_summary": (messages[-1]["content"][:500]
|
||||||
|
if messages else ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
result = self._call_provider(provider, model, messages, max_tokens, system)
|
||||||
|
except Exception as exc: # noqa: BLE001 — record and re-raise
|
||||||
|
task.write({"state": "failed", "error_message": str(exc),
|
||||||
|
"latency_ms": int((time.time() - start) * 1000)})
|
||||||
|
_logger.warning("AI task %s failed: %s", task.id, exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
latency = int((time.time() - start) * 1000)
|
||||||
|
usage = result.get("usage", {}) or {}
|
||||||
|
pt = usage.get("input_tokens", 0) or 0
|
||||||
|
ct = usage.get("output_tokens", 0) or 0
|
||||||
|
task.write({
|
||||||
|
"state": "done",
|
||||||
|
"prompt_tokens": pt,
|
||||||
|
"completion_tokens": ct,
|
||||||
|
"total_tokens": pt + ct,
|
||||||
|
"cost_usd": self._estimate_cost(model, pt, ct),
|
||||||
|
"latency_ms": latency,
|
||||||
|
"response_summary": (result.get("text") or "")[:1000],
|
||||||
|
})
|
||||||
|
return result, task
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 6 — AI draft wizard (the UI walkthrough for single-shot drafting)."""
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawAIDraftWizard(models.TransientModel):
|
||||||
|
_name = "familylaw.ai.draft.wizard"
|
||||||
|
_description = "AI Draft Wizard"
|
||||||
|
|
||||||
|
proceeding_id = fields.Many2one(
|
||||||
|
"familylaw.proceeding", string="Proceeding", required=True,
|
||||||
|
)
|
||||||
|
document_type = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
("pleading", "Pleading / Motion"),
|
||||||
|
("form", "Florida Supreme Court Form"),
|
||||||
|
("affidavit", "Affidavit"),
|
||||||
|
("order", "Proposed Order"),
|
||||||
|
("correspondence", "Correspondence"),
|
||||||
|
("notice", "Notice"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
default="pleading",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
doc_name = fields.Char(string="Document Title")
|
||||||
|
instruction = fields.Text(
|
||||||
|
string="Instruction",
|
||||||
|
required=True,
|
||||||
|
help="What to assemble, in plain language. Facts come from the matter.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_generate(self):
|
||||||
|
self.ensure_one()
|
||||||
|
doc = self.env["familylaw.document"].ai_assemble(
|
||||||
|
self.proceeding_id, self.document_type, self.instruction,
|
||||||
|
name=self.doc_name or False,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "ir.actions.act_window",
|
||||||
|
"res_model": "familylaw.document",
|
||||||
|
"res_id": doc.id,
|
||||||
|
"view_mode": "form",
|
||||||
|
"target": "current",
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 6 — Citation ledger (born unverified). Step 7 adds verification + Gate 2.
|
||||||
|
|
||||||
|
Every case-law citation is born `unverified` and is mechanically blocked from any
|
||||||
|
filing until it passes verification against a real reporter (CourtListener) — the
|
||||||
|
citation gate (Gate 2), built in Step 7. This model holds the citation and its
|
||||||
|
status; the verification workflow and the filing gate are layered on in Step 7.
|
||||||
|
|
||||||
|
This is TIER 3: the existence/holdings/good-law judgments are NOT authoritative
|
||||||
|
until verified and attorney-approved. Lawyers have been sanctioned for AI-
|
||||||
|
hallucinated cites — this ledger is what makes that mechanically impossible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawCitation(models.Model):
|
||||||
|
_name = "familylaw.citation"
|
||||||
|
_description = "Case-Law Citation (gated)"
|
||||||
|
_inherit = ["mail.thread"]
|
||||||
|
_order = "create_date desc"
|
||||||
|
|
||||||
|
document_id = fields.Many2one(
|
||||||
|
"familylaw.document", string="Document", ondelete="cascade", index=True,
|
||||||
|
)
|
||||||
|
proceeding_id = fields.Many2one(
|
||||||
|
"familylaw.proceeding",
|
||||||
|
related="document_id.proceeding_id", store=True, index=True,
|
||||||
|
)
|
||||||
|
case_id = fields.Many2one(
|
||||||
|
"familylaw.case", related="document_id.case_id", store=True, index=True,
|
||||||
|
)
|
||||||
|
citation_text = fields.Char(
|
||||||
|
string="Citation", required=True, tracking=True,
|
||||||
|
help="e.g. Smith v. Jones, 123 So. 3d 456 (Fla. 3d DCA 2015).",
|
||||||
|
)
|
||||||
|
case_name = fields.Char()
|
||||||
|
reporter = fields.Char()
|
||||||
|
court = fields.Char()
|
||||||
|
year = fields.Char()
|
||||||
|
proposition = fields.Text(
|
||||||
|
string="Cited For",
|
||||||
|
help="The proposition the citation supports in the draft.",
|
||||||
|
)
|
||||||
|
status = fields.Selection(
|
||||||
|
selection=[
|
||||||
|
("unverified", "Unverified"),
|
||||||
|
("verified", "Verified"),
|
||||||
|
("not_found", "Not Found"),
|
||||||
|
("rejected", "Rejected / Bad Law"),
|
||||||
|
],
|
||||||
|
default="unverified",
|
||||||
|
required=True,
|
||||||
|
copy=False,
|
||||||
|
tracking=True,
|
||||||
|
help="Born unverified. Must be 'verified' before it can enter a filing "
|
||||||
|
"(Gate 2, enforced in Step 7).",
|
||||||
|
)
|
||||||
|
is_verified = fields.Boolean(
|
||||||
|
compute="_compute_is_verified", store=True, string="Verified?",
|
||||||
|
)
|
||||||
|
source_ai_task_id = fields.Many2one("familylaw.ai.task", string="Proposed By (AI Task)")
|
||||||
|
|
||||||
|
@api.depends("status")
|
||||||
|
def _compute_is_verified(self):
|
||||||
|
for cite in self:
|
||||||
|
cite.is_verified = cite.status == "verified"
|
||||||
@@ -106,6 +106,9 @@ class FamilyLawDocument(models.Model):
|
|||||||
approved_date = fields.Datetime(
|
approved_date = fields.Datetime(
|
||||||
string="Approved On", readonly=True, copy=False, tracking=True,
|
string="Approved On", readonly=True, copy=False, tracking=True,
|
||||||
)
|
)
|
||||||
|
citation_ids = fields.One2many(
|
||||||
|
"familylaw.citation", "document_id", string="Citations",
|
||||||
|
)
|
||||||
|
|
||||||
# --- gate helpers -------------------------------------------------------
|
# --- gate helpers -------------------------------------------------------
|
||||||
def _ensure_attorney(self):
|
def _ensure_attorney(self):
|
||||||
@@ -178,6 +181,62 @@ class FamilyLawDocument(models.Model):
|
|||||||
doc.message_post(body=_("Reset to draft."))
|
doc.message_post(body=_("Reset to draft."))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# --- AI drafting agent (Step 6) -----------------------------------------
|
||||||
|
@api.model
|
||||||
|
def ai_assemble(self, proceeding, document_type, instruction, *,
|
||||||
|
name=None, confidential=True):
|
||||||
|
"""Single-shot AI assembly of a draft document.
|
||||||
|
|
||||||
|
Produces a document BORN IN ai_draft (never auto-approved) and records the
|
||||||
|
proposed citations BORN UNVERIFIED. The legal content is NOT authoritative
|
||||||
|
until an attorney reviews and approves it (Gate 1) and every citation is
|
||||||
|
verified (Gate 2, Step 7). Returns the new document.
|
||||||
|
|
||||||
|
The Claude call is routed + logged through familylaw.ai.client (mocked in
|
||||||
|
tests). In production this method is wrapped in queue_job (with_delay).
|
||||||
|
"""
|
||||||
|
proceeding = proceeding if hasattr(proceeding, "id") else \
|
||||||
|
self.env["familylaw.proceeding"].browse(proceeding)
|
||||||
|
system = (
|
||||||
|
"You are a paralegal assistant assembling a DRAFT for licensed-attorney "
|
||||||
|
"review. You do not give legal advice or final opinions. Surface "
|
||||||
|
"authority for the attorney to verify; never assert a citation is good "
|
||||||
|
"law yourself."
|
||||||
|
)
|
||||||
|
messages = [{"role": "user", "content": instruction}]
|
||||||
|
result, task = self.env["familylaw.ai.client"].generate(
|
||||||
|
"draft_document", messages, system=system,
|
||||||
|
case=proceeding.case_id, proceeding=proceeding,
|
||||||
|
confidential=confidential,
|
||||||
|
name=_("Draft: %s") % (name or document_type),
|
||||||
|
)
|
||||||
|
|
||||||
|
doc = self.create({
|
||||||
|
"name": name or _("AI Draft — %s") % document_type,
|
||||||
|
"proceeding_id": proceeding.id,
|
||||||
|
"document_type": document_type,
|
||||||
|
"source": "ai",
|
||||||
|
"state": "ai_draft",
|
||||||
|
"body": result.get("text") or "",
|
||||||
|
})
|
||||||
|
task.document_id = doc.id
|
||||||
|
|
||||||
|
# Citations proposed by the AI are born UNVERIFIED.
|
||||||
|
Cit = self.env["familylaw.citation"]
|
||||||
|
for c in (result.get("citations") or []):
|
||||||
|
Cit.create({
|
||||||
|
"document_id": doc.id,
|
||||||
|
"citation_text": c.get("citation_text") or c.get("text") or _("(unparsed)"),
|
||||||
|
"case_name": c.get("case_name"),
|
||||||
|
"reporter": c.get("reporter"),
|
||||||
|
"court": c.get("court"),
|
||||||
|
"year": c.get("year"),
|
||||||
|
"proposition": c.get("proposition"),
|
||||||
|
"status": "unverified",
|
||||||
|
"source_ai_task_id": task.id,
|
||||||
|
})
|
||||||
|
return doc
|
||||||
|
|
||||||
# --- outbound actions (Gate 1 enforced) ---------------------------------
|
# --- outbound actions (Gate 1 enforced) ---------------------------------
|
||||||
def action_mark_filed(self):
|
def action_mark_filed(self):
|
||||||
self._ensure_attorney()
|
self._ensure_attorney()
|
||||||
|
|||||||
@@ -22,3 +22,8 @@ access_familylaw_affidavit_user,familylaw.financial.affidavit staff,model_family
|
|||||||
access_familylaw_affidavit_attorney,familylaw.financial.affidavit attorney,model_familylaw_financial_affidavit,group_familylaw_attorney,1,1,1,1
|
access_familylaw_affidavit_attorney,familylaw.financial.affidavit attorney,model_familylaw_financial_affidavit,group_familylaw_attorney,1,1,1,1
|
||||||
access_familylaw_fin_line_user,familylaw.fin.line staff,model_familylaw_fin_line,group_familylaw_user,1,1,1,0
|
access_familylaw_fin_line_user,familylaw.fin.line staff,model_familylaw_fin_line,group_familylaw_user,1,1,1,0
|
||||||
access_familylaw_fin_line_attorney,familylaw.fin.line attorney,model_familylaw_fin_line,group_familylaw_attorney,1,1,1,1
|
access_familylaw_fin_line_attorney,familylaw.fin.line attorney,model_familylaw_fin_line,group_familylaw_attorney,1,1,1,1
|
||||||
|
access_familylaw_ai_task_user,familylaw.ai.task staff,model_familylaw_ai_task,group_familylaw_user,1,1,1,0
|
||||||
|
access_familylaw_ai_task_attorney,familylaw.ai.task attorney,model_familylaw_ai_task,group_familylaw_attorney,1,1,1,1
|
||||||
|
access_familylaw_citation_user,familylaw.citation staff,model_familylaw_citation,group_familylaw_user,1,1,1,0
|
||||||
|
access_familylaw_citation_attorney,familylaw.citation attorney,model_familylaw_citation,group_familylaw_attorney,1,1,1,1
|
||||||
|
access_familylaw_ai_draft_wizard_user,familylaw.ai.draft.wizard user,model_familylaw_ai_draft_wizard,group_familylaw_user,1,1,1,1
|
||||||
|
|||||||
|
@@ -3,3 +3,4 @@ from . import test_step2
|
|||||||
from . import test_step3
|
from . import test_step3
|
||||||
from . import test_step4
|
from . import test_step4
|
||||||
from . import test_step5
|
from . import test_step5
|
||||||
|
from . import test_step6
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 6 tests — Claude client + ai.task ledger (single-shot).
|
||||||
|
|
||||||
|
odoo -d <db> -u activeblue_familylaw --test-enable \
|
||||||
|
--test-tags familylaw_step6 --stop-after-init
|
||||||
|
|
||||||
|
External calls are ALWAYS mocked (patch familylaw.ai.client._call_provider). Tests
|
||||||
|
never hit the network. We assert on what gets WRITTEN TO ODOO:
|
||||||
|
* the document is born ai_draft;
|
||||||
|
* an ai.task is logged with model + token cost + latency;
|
||||||
|
* proposed citations are born unverified;
|
||||||
|
* the per-task token ceiling caps max_tokens;
|
||||||
|
* trust/billing routing RAISES before any provider call;
|
||||||
|
* a provider error marks the task failed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, new_test_user, tagged
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
CLIENT = "odoo.addons.activeblue_familylaw.models.familylaw_ai.FamilyLawAIClient"
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_response(text="DRAFT BODY", citations=None, pt=100, ct=200):
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"usage": {"input_tokens": pt, "output_tokens": ct},
|
||||||
|
"citations": citations or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install", "familylaw", "familylaw_step6")
|
||||||
|
class TestStep6AIClient(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env["res.partner"].create({"name": "AI Client"})
|
||||||
|
cls.case = cls.env["familylaw.case"].create({
|
||||||
|
"name": "AI Matter",
|
||||||
|
"client_id": cls.partner.id,
|
||||||
|
"case_type": "support_modification",
|
||||||
|
})
|
||||||
|
cls.proc = cls.case.proceeding_ids[0]
|
||||||
|
cls.Doc = cls.env["familylaw.document"]
|
||||||
|
cls.Task = cls.env["familylaw.ai.task"]
|
||||||
|
cls.attorney = new_test_user(
|
||||||
|
cls.env, login="fl_atty6", name="Attorney 6",
|
||||||
|
email="atty6@example.com",
|
||||||
|
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
|
||||||
|
)
|
||||||
|
# configure a key + model so config-dependent code paths work
|
||||||
|
icp = cls.env["ir.config_parameter"].sudo()
|
||||||
|
icp.set_param("familylaw.anthropic_api_key", "test-key")
|
||||||
|
icp.set_param("familylaw.model", "claude-sonnet-4-6")
|
||||||
|
icp.set_param("familylaw.token_ceiling", "2048")
|
||||||
|
|
||||||
|
# --- drafting produces an unapproved draft ------------------------------
|
||||||
|
def test_01_document_born_ai_draft(self):
|
||||||
|
with patch(CLIENT + "._call_provider", return_value=_fake_response()):
|
||||||
|
doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft a motion.")
|
||||||
|
self.assertEqual(doc.state, "ai_draft")
|
||||||
|
self.assertEqual(doc.source, "ai")
|
||||||
|
self.assertIn("DRAFT BODY", doc.body)
|
||||||
|
|
||||||
|
# --- ai.task logged with economics --------------------------------------
|
||||||
|
def test_02_task_logged_with_cost_and_latency(self):
|
||||||
|
with patch(CLIENT + "._call_provider",
|
||||||
|
return_value=_fake_response(pt=100, ct=200)):
|
||||||
|
doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft a motion.")
|
||||||
|
task = self.Task.search([("document_id", "=", doc.id)], limit=1)
|
||||||
|
self.assertTrue(task)
|
||||||
|
self.assertEqual(task.state, "done")
|
||||||
|
self.assertEqual(task.model_used, "claude-sonnet-4-6")
|
||||||
|
self.assertEqual(task.prompt_tokens, 100)
|
||||||
|
self.assertEqual(task.completion_tokens, 200)
|
||||||
|
self.assertEqual(task.total_tokens, 300)
|
||||||
|
self.assertGreater(task.cost_usd, 0.0)
|
||||||
|
self.assertGreaterEqual(task.latency_ms, 0)
|
||||||
|
|
||||||
|
# --- citations born unverified ------------------------------------------
|
||||||
|
def test_03_citations_born_unverified(self):
|
||||||
|
cites = [{"citation_text": "Smith v. Jones, 1 So.3d 2 (Fla. 3d DCA 2010)",
|
||||||
|
"proposition": "X"}]
|
||||||
|
with patch(CLIENT + "._call_provider",
|
||||||
|
return_value=_fake_response(citations=cites)):
|
||||||
|
doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft with authority.")
|
||||||
|
self.assertEqual(len(doc.citation_ids), 1)
|
||||||
|
self.assertEqual(doc.citation_ids.status, "unverified")
|
||||||
|
self.assertFalse(doc.citation_ids.is_verified)
|
||||||
|
|
||||||
|
# --- token ceiling caps max_tokens --------------------------------------
|
||||||
|
def test_04_token_ceiling_caps_max_tokens(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _spy(self2, provider, model, messages, max_tokens, system=None):
|
||||||
|
captured["max_tokens"] = max_tokens
|
||||||
|
return _fake_response()
|
||||||
|
|
||||||
|
with patch(CLIENT + "._call_provider", _spy):
|
||||||
|
# request far above the ceiling (2048)
|
||||||
|
self.env["familylaw.ai.client"].generate(
|
||||||
|
"draft_document", [{"role": "user", "content": "hi"}],
|
||||||
|
max_tokens=999999,
|
||||||
|
)
|
||||||
|
self.assertEqual(captured["max_tokens"], 2048)
|
||||||
|
|
||||||
|
# --- trust/billing never reaches a model --------------------------------
|
||||||
|
def test_05_trust_billing_blocked_before_call(self):
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def _spy(*a, **k):
|
||||||
|
calls["n"] += 1
|
||||||
|
return _fake_response()
|
||||||
|
|
||||||
|
with patch(CLIENT + "._call_provider", _spy):
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.env["familylaw.ai.client"].generate(
|
||||||
|
"trust_billing", [{"role": "user", "content": "reconcile"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(calls["n"], 0, "Provider must NOT be called for trust/billing.")
|
||||||
|
|
||||||
|
def test_06_route_model_raises_for_iota(self):
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.env["familylaw.ai.client"]._route_model("iota")
|
||||||
|
|
||||||
|
# --- provider failure marks task failed ---------------------------------
|
||||||
|
def test_07_provider_error_marks_task_failed(self):
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise ValueError("network down")
|
||||||
|
|
||||||
|
with patch(CLIENT + "._call_provider", _boom):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.env["familylaw.ai.client"].generate(
|
||||||
|
"draft_document", [{"role": "user", "content": "hi"}],
|
||||||
|
case=self.case,
|
||||||
|
)
|
||||||
|
task = self.Task.search([("case_id", "=", self.case.id),
|
||||||
|
("state", "=", "failed")], limit=1)
|
||||||
|
self.assertTrue(task)
|
||||||
|
self.assertIn("network down", task.error_message)
|
||||||
|
|
||||||
|
# --- cost estimate is deterministic -------------------------------------
|
||||||
|
def test_08_cost_estimate(self):
|
||||||
|
client = self.env["familylaw.ai.client"]
|
||||||
|
# sonnet: in 3.0 / out 15.0 per 1M
|
||||||
|
cost = client._estimate_cost("claude-sonnet-4-6", 1_000_000, 1_000_000)
|
||||||
|
self.assertAlmostEqual(cost, 18.0, places=4)
|
||||||
|
|
||||||
|
# --- missing API key raises (config gate) -------------------------------
|
||||||
|
def test_09_missing_key_raises(self):
|
||||||
|
self.env["ir.config_parameter"].sudo().set_param(
|
||||||
|
"familylaw.anthropic_api_key", "")
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.env["familylaw.ai.client"]._get_api_key()
|
||||||
|
|
||||||
|
# --- ai draft is NOT auto-approved (Gate 1 intact) ----------------------
|
||||||
|
def test_10_ai_draft_not_auto_approved(self):
|
||||||
|
with patch(CLIENT + "._call_provider", return_value=_fake_response()):
|
||||||
|
doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft.")
|
||||||
|
self.assertNotEqual(doc.state, "approved")
|
||||||
|
# even a real attorney cannot file it while unapproved (Gate 1)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
doc.with_user(self.attorney).action_mark_filed()
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<!-- ===== AI task ledger ===== -->
|
||||||
|
<record id="view_familylaw_ai_task_list" model="ir.ui.view">
|
||||||
|
<field name="name">familylaw.ai.task.list</field>
|
||||||
|
<field name="model">familylaw.ai.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Tasks">
|
||||||
|
<field name="create_date"/>
|
||||||
|
<field name="task_type"/>
|
||||||
|
<field name="model_used"/>
|
||||||
|
<field name="case_id"/>
|
||||||
|
<field name="total_tokens"/>
|
||||||
|
<field name="cost_usd"/>
|
||||||
|
<field name="latency_ms"/>
|
||||||
|
<field name="state" widget="badge"
|
||||||
|
decoration-success="state == 'done'"
|
||||||
|
decoration-danger="state in ('failed','blocked')"
|
||||||
|
decoration-info="state in ('queued','running')"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_familylaw_ai_task_form" model="ir.ui.view">
|
||||||
|
<field name="name">familylaw.ai.task.form</field>
|
||||||
|
<field name="model">familylaw.ai.task</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="AI Task">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group string="Task">
|
||||||
|
<field name="task_type"/>
|
||||||
|
<field name="provider"/>
|
||||||
|
<field name="model_used"/>
|
||||||
|
<field name="state"/>
|
||||||
|
</group>
|
||||||
|
<group string="Economics">
|
||||||
|
<field name="prompt_tokens"/>
|
||||||
|
<field name="completion_tokens"/>
|
||||||
|
<field name="total_tokens"/>
|
||||||
|
<field name="token_ceiling"/>
|
||||||
|
<field name="cost_usd"/>
|
||||||
|
<field name="latency_ms"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Links">
|
||||||
|
<field name="case_id"/>
|
||||||
|
<field name="proceeding_id"/>
|
||||||
|
<field name="document_id"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Request"><field name="request_summary"/></page>
|
||||||
|
<page string="Response"><field name="response_summary"/></page>
|
||||||
|
<page string="Error" invisible="not error_message">
|
||||||
|
<field name="error_message"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_familylaw_ai_task" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Tasks</field>
|
||||||
|
<field name="res_model">familylaw.ai.task</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== Citations ===== -->
|
||||||
|
<record id="view_familylaw_citation_list" model="ir.ui.view">
|
||||||
|
<field name="name">familylaw.citation.list</field>
|
||||||
|
<field name="model">familylaw.citation</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Citations"
|
||||||
|
decoration-success="status == 'verified'"
|
||||||
|
decoration-danger="status in ('not_found','rejected')"
|
||||||
|
decoration-warning="status == 'unverified'">
|
||||||
|
<field name="citation_text"/>
|
||||||
|
<field name="case_id"/>
|
||||||
|
<field name="document_id"/>
|
||||||
|
<field name="status" widget="badge"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_familylaw_citation_form" model="ir.ui.view">
|
||||||
|
<field name="name">familylaw.citation.form</field>
|
||||||
|
<field name="model">familylaw.citation</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Citation">
|
||||||
|
<sheet>
|
||||||
|
<div class="alert alert-warning" role="alert"
|
||||||
|
invisible="status == 'verified'">
|
||||||
|
Unverified — cannot enter a filing until verified (Gate 2).
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="citation_text"/>
|
||||||
|
<field name="case_name"/>
|
||||||
|
<field name="reporter"/>
|
||||||
|
<field name="court"/>
|
||||||
|
<field name="year"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="status"/>
|
||||||
|
<field name="document_id"/>
|
||||||
|
<field name="case_id" readonly="1"/>
|
||||||
|
<field name="source_ai_task_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Cited For"><field name="proposition" nolabel="1"/></group>
|
||||||
|
</sheet>
|
||||||
|
<div class="oe_chatter">
|
||||||
|
<field name="message_follower_ids"/>
|
||||||
|
<field name="message_ids"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_familylaw_citation" model="ir.actions.act_window">
|
||||||
|
<field name="name">Citations</field>
|
||||||
|
<field name="res_model">familylaw.citation</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- ===== AI draft wizard (walkthrough) ===== -->
|
||||||
|
<record id="view_familylaw_ai_draft_wizard_form" model="ir.ui.view">
|
||||||
|
<field name="name">familylaw.ai.draft.wizard.form</field>
|
||||||
|
<field name="model">familylaw.ai.draft.wizard</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="AI Draft">
|
||||||
|
<sheet>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
The AI assembles a DRAFT for attorney review. It is born
|
||||||
|
unapproved; any citations are born unverified. Nothing here
|
||||||
|
is authoritative until an attorney approves it.
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<field name="proceeding_id"/>
|
||||||
|
<field name="document_type"/>
|
||||||
|
<field name="doc_name"/>
|
||||||
|
<field name="instruction"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
<footer>
|
||||||
|
<button name="action_generate" type="object"
|
||||||
|
string="Assemble Draft" class="btn-primary"/>
|
||||||
|
<button string="Cancel" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_familylaw_ai_draft_wizard" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Draft</field>
|
||||||
|
<field name="res_model">familylaw.ai.draft.wizard</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -79,6 +79,17 @@
|
|||||||
<page string="Body" name="body">
|
<page string="Body" name="body">
|
||||||
<field name="body"/>
|
<field name="body"/>
|
||||||
</page>
|
</page>
|
||||||
|
<page string="Citations" name="citations">
|
||||||
|
<field name="citation_ids">
|
||||||
|
<list decoration-success="status == 'verified'"
|
||||||
|
decoration-warning="status == 'unverified'"
|
||||||
|
decoration-danger="status in ('not_found','rejected')">
|
||||||
|
<field name="citation_text"/>
|
||||||
|
<field name="proposition"/>
|
||||||
|
<field name="status" widget="badge"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
</notebook>
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
<div class="oe_chatter">
|
<div class="oe_chatter">
|
||||||
|
|||||||
@@ -41,6 +41,21 @@
|
|||||||
action="action_familylaw_affidavit"
|
action="action_familylaw_affidavit"
|
||||||
sequence="40"/>
|
sequence="40"/>
|
||||||
|
|
||||||
|
<!-- Citations -->
|
||||||
|
<menuitem id="menu_familylaw_citations"
|
||||||
|
name="Citations"
|
||||||
|
parent="menu_familylaw_root"
|
||||||
|
action="action_familylaw_citation"
|
||||||
|
sequence="50"/>
|
||||||
|
|
||||||
|
<!-- AI Tasks (audit ledger) -->
|
||||||
|
<menuitem id="menu_familylaw_ai_tasks"
|
||||||
|
name="AI Tasks"
|
||||||
|
parent="menu_familylaw_root"
|
||||||
|
action="action_familylaw_ai_task"
|
||||||
|
sequence="60"
|
||||||
|
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||||
|
|
||||||
<!-- Configuration placeholder (populated in later steps) -->
|
<!-- Configuration placeholder (populated in later steps) -->
|
||||||
<menuitem id="menu_familylaw_config"
|
<menuitem id="menu_familylaw_config"
|
||||||
name="Configuration"
|
name="Configuration"
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
string="Seed Standard Deadlines"/>
|
string="Seed Standard Deadlines"/>
|
||||||
<button name="action_seed_disclosure_items" type="object"
|
<button name="action_seed_disclosure_items" type="object"
|
||||||
string="Seed Disclosure Checklist"/>
|
string="Seed Disclosure Checklist"/>
|
||||||
|
<button name="%(action_familylaw_ai_draft_wizard)d" type="action"
|
||||||
|
string="AI Draft"
|
||||||
|
context="{'default_proceeding_id': id}"/>
|
||||||
<button name="action_close_proceeding" type="object"
|
<button name="action_close_proceeding" type="object"
|
||||||
string="Close Proceeding"
|
string="Close Proceeding"
|
||||||
invisible="state == 'closed'"/>
|
invisible="state == 'closed'"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user