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 -*-
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -3,3 +3,4 @@ from . import test_step2
|
||||
from . import test_step3
|
||||
from . import test_step4
|
||||
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">
|
||||
<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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'"/>
|
||||
|
||||
Reference in New Issue
Block a user