diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py index 76040ea..655267c 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py @@ -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", diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py index 5013c24..3ce3e32 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai.py new file mode 100644 index 0000000..67c5b72 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai.py @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai_wizard.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai_wizard.py new file mode 100644 index 0000000..89c5125 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_ai_wizard.py @@ -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", + } diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_citation.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_citation.py new file mode 100644 index 0000000..3839741 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_citation.py @@ -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" diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py index 9ec4501..9443c5b 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py @@ -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() diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv index c90ea5f..0bfd70d 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py index 18318b6..acae904 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_step2 from . import test_step3 from . import test_step4 from . import test_step5 +from . import test_step6 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step6.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step6.py new file mode 100644 index 0000000..b48f281 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step6.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""STEP 6 tests — Claude client + ai.task ledger (single-shot). + + odoo -d -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() diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_ai_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_ai_views.xml new file mode 100644 index 0000000..2a853ec --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_ai_views.xml @@ -0,0 +1,163 @@ + + + + + + familylaw.ai.task.list + familylaw.ai.task + + + + + + + + + + + + + + + + familylaw.ai.task.form + familylaw.ai.task + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + AI Tasks + familylaw.ai.task + list,form + + + + + familylaw.citation.list + familylaw.citation + + + + + + + + + + + + familylaw.citation.form + familylaw.citation + +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + + Citations + familylaw.citation + list,form + + + + + familylaw.ai.draft.wizard.form + familylaw.ai.draft.wizard + +
+ + + + + + + + + +
+
+
+
+
+ + + AI Draft + familylaw.ai.draft.wizard + form + new + + +
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml index 65c2d54..de9c417 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml @@ -79,6 +79,17 @@ + + + + + + + + +
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml index 2d282d2..ea736ea 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml @@ -41,6 +41,21 @@ action="action_familylaw_affidavit" sequence="40"/> + + + + + +