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:
tocmo0nlord
2026-06-02 04:10:01 +00:00
parent 82f9d3c0e8
commit 941da091b8
13 changed files with 768 additions and 5 deletions

View File

@@ -1,16 +1,17 @@
# -*- coding: utf-8 -*-
{
"name": "Active Blue Family Law",
"version": "18.0.5.0.0",
"version": "18.0.6.0.0",
"category": "Services/Legal",
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
"description": """
Active Blue Family Law
======================
Case-management platform for a Florida family-law practice, built in verifiable
steps. Step 1: case spine. Step 2: parties/children/issues/proceedings, conflict
screening, intake. Step 3: documents + review gate. Step 4: deadline engine
(per-proceeding clocks, weekend roll, overdue cron, calendar mirror).
steps. 1: case spine. 2: parties/children/issues/proceedings, conflict screening,
intake. 3: documents + review gate. 4: deadlines. 5: mandatory disclosure.
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.
""",
@@ -29,10 +30,11 @@ Each step adds one vertical, independently testable slice. See BUILD_PLAN.md.
"views/familylaw_party_views.xml",
"views/familylaw_child_views.xml",
"views/familylaw_issue_views.xml",
"views/familylaw_proceeding_views.xml",
"views/familylaw_document_views.xml",
"views/familylaw_deadline_views.xml",
"views/familylaw_disclosure_views.xml",
"views/familylaw_ai_views.xml",
"views/familylaw_proceeding_views.xml",
"views/familylaw_intake_views.xml",
"views/familylaw_case_views.xml",
"views/familylaw_menus.xml",

View File

@@ -8,3 +8,6 @@ from . import familylaw_intake
from . import familylaw_document
from . import familylaw_deadline
from . import familylaw_disclosure
from . import familylaw_ai
from . import familylaw_citation
from . import familylaw_ai_wizard

View File

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

View File

@@ -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",
}

View File

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

View File

@@ -106,6 +106,9 @@ class FamilyLawDocument(models.Model):
approved_date = fields.Datetime(
string="Approved On", readonly=True, copy=False, tracking=True,
)
citation_ids = fields.One2many(
"familylaw.citation", "document_id", string="Citations",
)
# --- gate helpers -------------------------------------------------------
def _ensure_attorney(self):
@@ -178,6 +181,62 @@ class FamilyLawDocument(models.Model):
doc.message_post(body=_("Reset to draft."))
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) ---------------------------------
def action_mark_filed(self):
self._ensure_attorney()

View File

@@ -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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
22 access_familylaw_affidavit_attorney familylaw.financial.affidavit attorney model_familylaw_financial_affidavit group_familylaw_attorney 1 1 1 1
23 access_familylaw_fin_line_user familylaw.fin.line staff model_familylaw_fin_line group_familylaw_user 1 1 1 0
24 access_familylaw_fin_line_attorney familylaw.fin.line attorney model_familylaw_fin_line group_familylaw_attorney 1 1 1 1
25 access_familylaw_ai_task_user familylaw.ai.task staff model_familylaw_ai_task group_familylaw_user 1 1 1 0
26 access_familylaw_ai_task_attorney familylaw.ai.task attorney model_familylaw_ai_task group_familylaw_attorney 1 1 1 1
27 access_familylaw_citation_user familylaw.citation staff model_familylaw_citation group_familylaw_user 1 1 1 0
28 access_familylaw_citation_attorney familylaw.citation attorney model_familylaw_citation group_familylaw_attorney 1 1 1 1
29 access_familylaw_ai_draft_wizard_user familylaw.ai.draft.wizard user model_familylaw_ai_draft_wizard group_familylaw_user 1 1 1 1

View File

@@ -3,3 +3,4 @@ from . import test_step2
from . import test_step3
from . import test_step4
from . import test_step5
from . import test_step6

View File

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

View File

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

View File

@@ -79,6 +79,17 @@
<page string="Body" name="body">
<field name="body"/>
</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>
</sheet>
<div class="oe_chatter">

View File

@@ -41,6 +41,21 @@
action="action_familylaw_affidavit"
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) -->
<menuitem id="menu_familylaw_config"
name="Configuration"

View File

@@ -28,6 +28,9 @@
string="Seed Standard Deadlines"/>
<button name="action_seed_disclosure_items" type="object"
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"
string="Close Proceeding"
invisible="state == 'closed'"/>