Step 7: citation gate (Gate 2) — fail-closed verification, filing block, research loop
familylaw.citation.verifier (AbstractModel): fail-CLOSED engine - _verify_courtlistener (primary) + _verify_fallback (secondary) isolate the only network calls (mock points). FL coverage caveat documented. - verify_citation(): only an affirmative found+good_law -> 'verified'; found+!good -> 'rejected'; affirmative no-record -> 'not_found'; any source unavailable -> stays 'unverified' (FAIL CLOSED, never assume good). Records source/date/note, posts to chatter. familylaw.citation: verification fields + action_verify (engine), attorney-only action_attorney_verify (audited human attestation), action_reject. familylaw.document — Gate 2 wired: - unverified_citation_count; _ensure_citations_verified() raises listing bad cites - action_mark_filed / action_mark_sent now enforce Gate 1 AND Gate 2 - action_request_filing validates both gates (staff-runnable); action_verify_citations - Gate 2 banner + Verify Citations / Request Filing buttons on the form familylaw.research.agent (AbstractModel): in-Odoo research loop (propose -> verify each fail-closed -> synthesize). Memo born ai_draft; synthesis uses only verified authority; unverified cites block filing. FastAPI orchestrator is the optional swap. Tests (familylaw_step7, all externals mocked): verified only on found+good_law, not_found/rejected paths, primary-down+fallback verifies, both-down fail-closed, attorney attest (+permission), filing BLOCKED on unverified / allowed when all verified / no-citations files, send blocked, unverified count, research loop produces ai_draft + verifies, hallucinated cite -> not_found -> blocks filing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
"name": "Active Blue Family Law",
|
"name": "Active Blue Family Law",
|
||||||
"version": "18.0.6.0.0",
|
"version": "18.0.7.0.0",
|
||||||
"category": "Services/Legal",
|
"category": "Services/Legal",
|
||||||
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
|
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
|
||||||
"description": """
|
"description": """
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ from . import familylaw_deadline
|
|||||||
from . import familylaw_disclosure
|
from . import familylaw_disclosure
|
||||||
from . import familylaw_ai
|
from . import familylaw_ai
|
||||||
from . import familylaw_citation
|
from . import familylaw_citation
|
||||||
|
from . import familylaw_verifier
|
||||||
|
from . import familylaw_research
|
||||||
from . import familylaw_ai_wizard
|
from . import familylaw_ai_wizard
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ hallucinated cites — this ledger is what makes that mechanically impossible.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney"
|
||||||
|
|
||||||
|
|
||||||
class FamilyLawCitation(models.Model):
|
class FamilyLawCitation(models.Model):
|
||||||
@@ -61,7 +64,43 @@ class FamilyLawCitation(models.Model):
|
|||||||
)
|
)
|
||||||
source_ai_task_id = fields.Many2one("familylaw.ai.task", string="Proposed By (AI Task)")
|
source_ai_task_id = fields.Many2one("familylaw.ai.task", string="Proposed By (AI Task)")
|
||||||
|
|
||||||
|
verification_source = fields.Char(string="Verified Via", readonly=True)
|
||||||
|
verification_date = fields.Datetime(string="Verified On", readonly=True)
|
||||||
|
verification_note = fields.Text(string="Verification Note", readonly=True)
|
||||||
|
|
||||||
@api.depends("status")
|
@api.depends("status")
|
||||||
def _compute_is_verified(self):
|
def _compute_is_verified(self):
|
||||||
for cite in self:
|
for cite in self:
|
||||||
cite.is_verified = cite.status == "verified"
|
cite.is_verified = cite.status == "verified"
|
||||||
|
|
||||||
|
# --- verification (Gate 2 engine) ---------------------------------------
|
||||||
|
def action_verify(self):
|
||||||
|
"""Run the fail-closed verifier against each citation."""
|
||||||
|
verifier = self.env["familylaw.citation.verifier"]
|
||||||
|
for cite in self:
|
||||||
|
verifier.verify_citation(cite)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_attorney_verify(self):
|
||||||
|
"""Attorney override: the licensed attorney attests the cite is good law.
|
||||||
|
Still a deliberate, audited human act — not an automatic pass."""
|
||||||
|
if not self.env.user.has_group(ATTORNEY_GROUP):
|
||||||
|
raise UserError(
|
||||||
|
_("Only a licensed attorney may manually attest a citation.")
|
||||||
|
)
|
||||||
|
for cite in self:
|
||||||
|
cite.write({
|
||||||
|
"status": "verified",
|
||||||
|
"verification_source": "attorney",
|
||||||
|
"verification_date": fields.Datetime.now(),
|
||||||
|
"verification_note": _("Manually attested by %s.") % self.env.user.name,
|
||||||
|
})
|
||||||
|
cite.message_post(body=_("Citation attested by attorney %s.")
|
||||||
|
% self.env.user.name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_reject(self):
|
||||||
|
for cite in self:
|
||||||
|
cite.status = "rejected"
|
||||||
|
cite.message_post(body=_("Citation rejected."))
|
||||||
|
return True
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ class FamilyLawDocument(models.Model):
|
|||||||
citation_ids = fields.One2many(
|
citation_ids = fields.One2many(
|
||||||
"familylaw.citation", "document_id", string="Citations",
|
"familylaw.citation", "document_id", string="Citations",
|
||||||
)
|
)
|
||||||
|
unverified_citation_count = fields.Integer(
|
||||||
|
compute="_compute_unverified_citation_count",
|
||||||
|
string="Unverified Citations",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends("citation_ids.status")
|
||||||
|
def _compute_unverified_citation_count(self):
|
||||||
|
for doc in self:
|
||||||
|
doc.unverified_citation_count = len(
|
||||||
|
doc.citation_ids.filtered(lambda c: c.status != "verified")
|
||||||
|
)
|
||||||
|
|
||||||
# --- gate helpers -------------------------------------------------------
|
# --- gate helpers -------------------------------------------------------
|
||||||
def _ensure_attorney(self):
|
def _ensure_attorney(self):
|
||||||
@@ -130,6 +141,41 @@ class FamilyLawDocument(models.Model):
|
|||||||
state=not_approved[0].state)
|
state=not_approved[0].state)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _ensure_citations_verified(self):
|
||||||
|
"""Gate 2: refuse filing/sending if ANY citation is not verified.
|
||||||
|
|
||||||
|
Born-unverified, fail-closed verification (Step 7) makes an AI-hallucinated
|
||||||
|
cite mechanically unable to reach a filing."""
|
||||||
|
for doc in self:
|
||||||
|
bad = doc.citation_ids.filtered(lambda c: c.status != "verified")
|
||||||
|
if bad:
|
||||||
|
raise UserError(
|
||||||
|
_("Document '%(name)s' has %(n)d unverified citation(s). Every "
|
||||||
|
"citation must be verified against a real reporter before the "
|
||||||
|
"document can be filed or sent (the citation gate). Unverified: "
|
||||||
|
"%(list)s",
|
||||||
|
name=doc.name, n=len(bad),
|
||||||
|
list="; ".join(bad.mapped("citation_text")))
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_verify_citations(self):
|
||||||
|
"""Run the fail-closed verifier over every citation on this document."""
|
||||||
|
for doc in self:
|
||||||
|
doc.citation_ids.action_verify()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_request_filing(self):
|
||||||
|
"""Validate BOTH gates and flag the document ready for filing prep.
|
||||||
|
Available to staff to assemble; the actual filing (action_mark_filed) stays
|
||||||
|
attorney-only."""
|
||||||
|
self._ensure_approved()
|
||||||
|
self._ensure_citations_verified()
|
||||||
|
for doc in self:
|
||||||
|
doc.message_post(
|
||||||
|
body=_("Filing requested — review gate and citation gate both passed.")
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
# --- review workflow ----------------------------------------------------
|
# --- review workflow ----------------------------------------------------
|
||||||
def action_submit_for_review(self):
|
def action_submit_for_review(self):
|
||||||
for doc in self:
|
for doc in self:
|
||||||
@@ -240,7 +286,8 @@ class FamilyLawDocument(models.Model):
|
|||||||
# --- outbound actions (Gate 1 enforced) ---------------------------------
|
# --- outbound actions (Gate 1 enforced) ---------------------------------
|
||||||
def action_mark_filed(self):
|
def action_mark_filed(self):
|
||||||
self._ensure_attorney()
|
self._ensure_attorney()
|
||||||
self._ensure_approved()
|
self._ensure_approved() # Gate 1
|
||||||
|
self._ensure_citations_verified() # Gate 2
|
||||||
for doc in self:
|
for doc in self:
|
||||||
doc.state = "filed"
|
doc.state = "filed"
|
||||||
doc.message_post(body=_("Marked as filed by %s.") % self.env.user.name)
|
doc.message_post(body=_("Marked as filed by %s.") % self.env.user.name)
|
||||||
@@ -248,7 +295,8 @@ class FamilyLawDocument(models.Model):
|
|||||||
|
|
||||||
def action_mark_sent(self):
|
def action_mark_sent(self):
|
||||||
self._ensure_attorney()
|
self._ensure_attorney()
|
||||||
self._ensure_approved()
|
self._ensure_approved() # Gate 1
|
||||||
|
self._ensure_citations_verified() # Gate 2
|
||||||
for doc in self:
|
for doc in self:
|
||||||
doc.state = "sent"
|
doc.state = "sent"
|
||||||
doc.message_post(body=_("Marked as sent by %s.") % self.env.user.name)
|
doc.message_post(body=_("Marked as sent by %s.") % self.env.user.name)
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 7 — Research loop (search -> propose citations -> verify each -> synthesize).
|
||||||
|
|
||||||
|
The design allows the agentic research chain to run either in an external FastAPI
|
||||||
|
orchestrator OR as sequenced steps inside Odoo (an explicitly acceptable
|
||||||
|
alternative). This implements the in-Odoo path so the platform is self-contained;
|
||||||
|
the FastAPI orchestrator can replace run() later without changing callers.
|
||||||
|
|
||||||
|
TIER 3: every citation is born unverified and verified FAIL-CLOSED before the
|
||||||
|
synthesized memo can be filed; the memo itself is born ai_draft for attorney review.
|
||||||
|
Nothing here is authoritative.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawResearchAgent(models.AbstractModel):
|
||||||
|
_name = "familylaw.research.agent"
|
||||||
|
_description = "Case-law research loop"
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def run(self, proceeding, question):
|
||||||
|
"""Run the loop and return the synthesized DRAFT document.
|
||||||
|
|
||||||
|
1. PROPOSE — ask the model for candidate authority (citations born unverified)
|
||||||
|
2. VERIFY — run each through the fail-closed verifier
|
||||||
|
3. SYNTHESIZE — draft a memo; the draft is born ai_draft (attorney review)
|
||||||
|
|
||||||
|
Both model calls go through familylaw.ai.client (mocked in tests); the
|
||||||
|
verifier's network calls are mocked too.
|
||||||
|
"""
|
||||||
|
proceeding = proceeding if hasattr(proceeding, "id") else \
|
||||||
|
self.env["familylaw.proceeding"].browse(proceeding)
|
||||||
|
Client = self.env["familylaw.ai.client"]
|
||||||
|
Doc = self.env["familylaw.document"]
|
||||||
|
Cit = self.env["familylaw.citation"]
|
||||||
|
verifier = self.env["familylaw.citation.verifier"]
|
||||||
|
|
||||||
|
# 1. PROPOSE authority
|
||||||
|
propose_msg = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": _("Propose candidate Florida authority for: %s. Return "
|
||||||
|
"citations for the attorney to verify — do not assert good "
|
||||||
|
"law yourself.") % question,
|
||||||
|
}]
|
||||||
|
proposal, propose_task = Client.generate(
|
||||||
|
"research_propose", propose_msg,
|
||||||
|
case=proceeding.case_id, proceeding=proceeding,
|
||||||
|
name=_("Research: propose authority"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Synthesis document is born ai_draft; citations attach to it.
|
||||||
|
doc = Doc.create({
|
||||||
|
"name": _("Research Memo — %s") % (question[:60] or "untitled"),
|
||||||
|
"proceeding_id": proceeding.id,
|
||||||
|
"document_type": "other",
|
||||||
|
"source": "ai",
|
||||||
|
"state": "ai_draft",
|
||||||
|
})
|
||||||
|
propose_task.document_id = doc.id
|
||||||
|
|
||||||
|
citations = self.env["familylaw.citation"]
|
||||||
|
for c in (proposal.get("citations") or []):
|
||||||
|
cite = Cit.create({
|
||||||
|
"document_id": doc.id,
|
||||||
|
"citation_text": c.get("citation_text") or c.get("text") or _("(unparsed)"),
|
||||||
|
"case_name": c.get("case_name"),
|
||||||
|
"proposition": c.get("proposition"),
|
||||||
|
"status": "unverified",
|
||||||
|
"source_ai_task_id": propose_task.id,
|
||||||
|
})
|
||||||
|
citations |= cite
|
||||||
|
|
||||||
|
# 2. VERIFY each (fail-closed)
|
||||||
|
for cite in citations:
|
||||||
|
verifier.verify_citation(cite)
|
||||||
|
|
||||||
|
# 3. SYNTHESIZE using ONLY verified authority
|
||||||
|
verified = citations.filtered(lambda c: c.status == "verified")
|
||||||
|
verified_text = "\n".join(verified.mapped("citation_text")) or _("(none verified)")
|
||||||
|
synth_msg = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": _("Draft a research memo answering: %(q)s. Use ONLY these "
|
||||||
|
"verified citations:\n%(c)s", q=question, c=verified_text),
|
||||||
|
}]
|
||||||
|
synthesis, synth_task = Client.generate(
|
||||||
|
"research_synthesize", synth_msg,
|
||||||
|
case=proceeding.case_id, proceeding=proceeding,
|
||||||
|
name=_("Research: synthesize memo"),
|
||||||
|
)
|
||||||
|
synth_task.document_id = doc.id
|
||||||
|
doc.body = synthesis.get("text") or ""
|
||||||
|
doc.message_post(body=_(
|
||||||
|
"Research loop complete: %(p)d proposed, %(v)d verified. Memo is a DRAFT "
|
||||||
|
"for attorney review; unverified citations block filing.",
|
||||||
|
p=len(citations), v=len(verified),
|
||||||
|
))
|
||||||
|
return doc
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 7 — Citation verification (Gate 2 engine), FAIL-CLOSED + fallback source.
|
||||||
|
|
||||||
|
CourtListener is a single point of failure for the citation gate — the thing that
|
||||||
|
prevents AI-hallucinated-cite sanctions. So this verifier:
|
||||||
|
|
||||||
|
1. FAILS CLOSED — if the source is down, rate-limited, or has no record, the
|
||||||
|
citation NEVER becomes 'verified'. It stays blocked. We only set 'verified'
|
||||||
|
when a source AFFIRMATIVELY reports the case exists and is good law.
|
||||||
|
2. Has a FALLBACK source — if the primary (CourtListener) is unavailable, a
|
||||||
|
secondary source is consulted before giving up.
|
||||||
|
3. FL coverage caveat — confirm CourtListener (+fallback) actually cover Florida
|
||||||
|
DCA + Supreme Court family-law authority before relying on this in production.
|
||||||
|
|
||||||
|
The two network methods (_verify_courtlistener / _verify_fallback) are the ONLY
|
||||||
|
places that touch the network and are the mock points in tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PARAM_CL_TOKEN = "familylaw.courtlistener_token"
|
||||||
|
PARAM_CL_ENDPOINT = "familylaw.courtlistener_endpoint"
|
||||||
|
DEFAULT_CL_ENDPOINT = "https://www.courtlistener.com/api/rest/v4/citation-lookup/"
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyLawCitationVerifier(models.AbstractModel):
|
||||||
|
_name = "familylaw.citation.verifier"
|
||||||
|
_description = "Citation Verifier (fail-closed)"
|
||||||
|
|
||||||
|
# --- network methods (MOCK THESE IN TESTS) ------------------------------
|
||||||
|
def _verify_courtlistener(self, citation_text):
|
||||||
|
"""Primary source. Returns a dict {found, good_law, note} or raises on a
|
||||||
|
transport/rate-limit error (which the caller treats as 'unavailable').
|
||||||
|
Returning {'found': False} means the source AFFIRMATIVELY has no record."""
|
||||||
|
import requests
|
||||||
|
icp = self.env["ir.config_parameter"].sudo()
|
||||||
|
endpoint = icp.get_param(PARAM_CL_ENDPOINT) or DEFAULT_CL_ENDPOINT
|
||||||
|
token = icp.get_param(PARAM_CL_TOKEN)
|
||||||
|
headers = {}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = "Token %s" % token
|
||||||
|
resp = requests.post(
|
||||||
|
endpoint, headers=headers, data={"text": citation_text}, timeout=60,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
# CourtListener returns a list of matches; a non-empty match => found.
|
||||||
|
matches = data if isinstance(data, list) else data.get("results", [])
|
||||||
|
found = bool(matches)
|
||||||
|
# good-law / treatment is not a simple flag in the public API; default to
|
||||||
|
# found-implies-citable but flag for attorney. Conservative: good_law=found.
|
||||||
|
return {
|
||||||
|
"found": found,
|
||||||
|
"good_law": found,
|
||||||
|
"note": _("CourtListener returned %d match(es).") % len(matches or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _verify_fallback(self, citation_text):
|
||||||
|
"""Secondary source (e.g. a secondary reporter API / Google Scholar gateway).
|
||||||
|
Same contract as the primary. Not wired to a real provider by default —
|
||||||
|
returns None ('no fallback configured') so the verifier fails closed."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- decision (fail-closed) ---------------------------------------------
|
||||||
|
def verify_citation(self, citation):
|
||||||
|
text = citation.citation_text or ""
|
||||||
|
result = None
|
||||||
|
used_source = False
|
||||||
|
|
||||||
|
# primary
|
||||||
|
try:
|
||||||
|
result = self._verify_courtlistener(text)
|
||||||
|
used_source = "courtlistener"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_logger.warning("CourtListener unavailable for '%s': %s", text, exc)
|
||||||
|
result = None
|
||||||
|
|
||||||
|
# fallback only if primary unavailable or reported no record
|
||||||
|
if result is None or not result.get("found"):
|
||||||
|
try:
|
||||||
|
fb = self._verify_fallback(text)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
_logger.warning("Fallback verifier unavailable: %s", exc)
|
||||||
|
fb = None
|
||||||
|
if fb is not None and (result is None or fb.get("found")):
|
||||||
|
result = fb
|
||||||
|
used_source = "fallback"
|
||||||
|
|
||||||
|
# decide — only an affirmative found+good_law yields 'verified'
|
||||||
|
if result is None:
|
||||||
|
status = "unverified"
|
||||||
|
note = _("Verification source(s) unavailable — FAILED CLOSED (stays blocked).")
|
||||||
|
source = False
|
||||||
|
elif result.get("found") and result.get("good_law"):
|
||||||
|
status = "verified"
|
||||||
|
note = result.get("note") or _("Verified.")
|
||||||
|
source = used_source
|
||||||
|
elif result.get("found") and not result.get("good_law"):
|
||||||
|
status = "rejected"
|
||||||
|
note = result.get("note") or _("Found but flagged as not good law.")
|
||||||
|
source = used_source
|
||||||
|
else:
|
||||||
|
status = "not_found"
|
||||||
|
note = result.get("note") or _("No record found — stays blocked.")
|
||||||
|
source = used_source
|
||||||
|
|
||||||
|
citation.write({
|
||||||
|
"status": status,
|
||||||
|
"verification_source": source or False,
|
||||||
|
"verification_note": note,
|
||||||
|
"verification_date": fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
citation.message_post(
|
||||||
|
body=_("Verification result: %(s)s (%(src)s). %(note)s",
|
||||||
|
s=status, src=source or _("none"), note=note)
|
||||||
|
)
|
||||||
|
return status
|
||||||
@@ -4,3 +4,4 @@ from . import test_step3
|
|||||||
from . import test_step4
|
from . import test_step4
|
||||||
from . import test_step5
|
from . import test_step5
|
||||||
from . import test_step6
|
from . import test_step6
|
||||||
|
from . import test_step7
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""STEP 7 tests — citation gate (Gate 2): fail-closed verification + filing block.
|
||||||
|
|
||||||
|
odoo -d <db> -u activeblue_familylaw --test-enable \
|
||||||
|
--test-tags familylaw_step7 --stop-after-init
|
||||||
|
|
||||||
|
External calls ALWAYS mocked: the verifier's _verify_courtlistener / _verify_fallback
|
||||||
|
and the AI client's _call_provider. Tests assert on Odoo state:
|
||||||
|
* verified only on an affirmative found+good_law;
|
||||||
|
* primary down -> fallback used;
|
||||||
|
* both down -> stays unverified (FAIL CLOSED);
|
||||||
|
* not found -> not_found; bad law -> rejected;
|
||||||
|
* filing BLOCKED on any unverified citation, allowed when all verified;
|
||||||
|
* research loop produces an ai_draft memo and verifies proposed citations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from odoo.tests.common import TransactionCase, new_test_user, tagged
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
VERIFIER = ("odoo.addons.activeblue_familylaw.models.familylaw_verifier."
|
||||||
|
"FamilyLawCitationVerifier")
|
||||||
|
CLIENT = "odoo.addons.activeblue_familylaw.models.familylaw_ai.FamilyLawAIClient"
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_response(text="MEMO", citations=None, pt=10, ct=20):
|
||||||
|
return {"text": text, "usage": {"input_tokens": pt, "output_tokens": ct},
|
||||||
|
"citations": citations or []}
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install", "familylaw", "familylaw_step7")
|
||||||
|
class TestStep7CitationGate(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.attorney = new_test_user(
|
||||||
|
cls.env, login="fl_atty7", name="Attorney 7",
|
||||||
|
email="atty7@example.com",
|
||||||
|
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
|
||||||
|
)
|
||||||
|
cls.partner = cls.env["res.partner"].create({"name": "Cite Client"})
|
||||||
|
cls.case = cls.env["familylaw.case"].create({
|
||||||
|
"name": "Cite Matter", "client_id": cls.partner.id,
|
||||||
|
"case_type": "support_modification",
|
||||||
|
})
|
||||||
|
cls.proc = cls.case.proceeding_ids[0]
|
||||||
|
cls.Doc = cls.env["familylaw.document"]
|
||||||
|
cls.Cit = cls.env["familylaw.citation"]
|
||||||
|
icp = cls.env["ir.config_parameter"].sudo()
|
||||||
|
icp.set_param("familylaw.anthropic_api_key", "k")
|
||||||
|
icp.set_param("familylaw.model", "claude-sonnet-4-6")
|
||||||
|
|
||||||
|
def _doc(self):
|
||||||
|
return self.Doc.create({
|
||||||
|
"name": "Brief", "proceeding_id": self.proc.id,
|
||||||
|
"document_type": "pleading", "source": "manual",
|
||||||
|
})
|
||||||
|
|
||||||
|
def _cite(self, doc, text="Smith v. Jones, 1 So.3d 2 (Fla. 3d DCA 2010)"):
|
||||||
|
return self.Cit.create({"document_id": doc.id, "citation_text": text})
|
||||||
|
|
||||||
|
# --- verification decisions ---------------------------------------------
|
||||||
|
def test_01_primary_found_good_law_verified(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": True, "good_law": True, "note": "ok"}):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "verified")
|
||||||
|
self.assertEqual(cite.verification_source, "courtlistener")
|
||||||
|
|
||||||
|
def test_02_not_found_stays_blocked(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": False, "good_law": False}), \
|
||||||
|
patch(VERIFIER + "._verify_fallback", return_value=None):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "not_found")
|
||||||
|
self.assertFalse(cite.is_verified)
|
||||||
|
|
||||||
|
def test_03_bad_law_rejected(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": True, "good_law": False}):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "rejected")
|
||||||
|
|
||||||
|
# --- fail closed --------------------------------------------------------
|
||||||
|
def test_04_primary_down_no_fallback_stays_unverified(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
|
||||||
|
def _down(*a, **k):
|
||||||
|
raise ConnectionError("CourtListener 503")
|
||||||
|
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener", _down), \
|
||||||
|
patch(VERIFIER + "._verify_fallback", return_value=None):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "unverified") # FAIL CLOSED
|
||||||
|
self.assertFalse(cite.is_verified)
|
||||||
|
|
||||||
|
def test_05_primary_down_fallback_verifies(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
|
||||||
|
def _down(*a, **k):
|
||||||
|
raise ConnectionError("down")
|
||||||
|
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener", _down), \
|
||||||
|
patch(VERIFIER + "._verify_fallback",
|
||||||
|
return_value={"found": True, "good_law": True, "note": "fb"}):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "verified")
|
||||||
|
self.assertEqual(cite.verification_source, "fallback")
|
||||||
|
|
||||||
|
def test_06_both_down_fail_closed(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
|
||||||
|
def _down(*a, **k):
|
||||||
|
raise ConnectionError("down")
|
||||||
|
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener", _down), \
|
||||||
|
patch(VERIFIER + "._verify_fallback", side_effect=_down):
|
||||||
|
cite.action_verify()
|
||||||
|
self.assertEqual(cite.status, "unverified")
|
||||||
|
|
||||||
|
# --- attorney attestation -----------------------------------------------
|
||||||
|
def test_07_attorney_attest(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
cite.with_user(self.attorney).action_attorney_verify()
|
||||||
|
self.assertEqual(cite.status, "verified")
|
||||||
|
self.assertEqual(cite.verification_source, "attorney")
|
||||||
|
|
||||||
|
def test_08_attest_requires_attorney(self):
|
||||||
|
doc = self._doc(); cite = self._cite(doc)
|
||||||
|
para = new_test_user(
|
||||||
|
self.env, login="fl_para7", name="Para 7", email="p7@example.com",
|
||||||
|
groups="base.group_user,activeblue_familylaw.group_familylaw_user",
|
||||||
|
)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
cite.with_user(para).action_attorney_verify()
|
||||||
|
|
||||||
|
# --- THE FILING GATE (Gate 2) -------------------------------------------
|
||||||
|
def _approve(self, doc):
|
||||||
|
doc.action_submit_for_review()
|
||||||
|
doc.with_user(self.attorney).action_approve()
|
||||||
|
|
||||||
|
def test_09_filing_blocked_with_unverified_cite(self):
|
||||||
|
doc = self._doc()
|
||||||
|
self._cite(doc) # unverified
|
||||||
|
self._approve(doc)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
doc.with_user(self.attorney).action_mark_filed()
|
||||||
|
self.assertNotEqual(doc.state, "filed")
|
||||||
|
|
||||||
|
def test_10_filing_allowed_when_all_verified(self):
|
||||||
|
doc = self._doc()
|
||||||
|
cite = self._cite(doc)
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": True, "good_law": True}):
|
||||||
|
cite.action_verify()
|
||||||
|
self._approve(doc)
|
||||||
|
doc.with_user(self.attorney).action_mark_filed()
|
||||||
|
self.assertEqual(doc.state, "filed")
|
||||||
|
|
||||||
|
def test_11_request_filing_blocked_unverified(self):
|
||||||
|
doc = self._doc()
|
||||||
|
self._cite(doc)
|
||||||
|
self._approve(doc)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
doc.action_request_filing()
|
||||||
|
|
||||||
|
def test_12_doc_with_no_citations_can_file(self):
|
||||||
|
doc = self._doc()
|
||||||
|
self._approve(doc)
|
||||||
|
doc.with_user(self.attorney).action_mark_filed()
|
||||||
|
self.assertEqual(doc.state, "filed")
|
||||||
|
|
||||||
|
def test_13_send_blocked_with_unverified_cite(self):
|
||||||
|
doc = self._doc()
|
||||||
|
self._cite(doc)
|
||||||
|
self._approve(doc)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
doc.with_user(self.attorney).action_mark_sent()
|
||||||
|
|
||||||
|
def test_14_unverified_count(self):
|
||||||
|
doc = self._doc()
|
||||||
|
c1 = self._cite(doc, "A v. B")
|
||||||
|
self._cite(doc, "C v. D")
|
||||||
|
self.assertEqual(doc.unverified_citation_count, 2)
|
||||||
|
with patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": True, "good_law": True}):
|
||||||
|
c1.action_verify()
|
||||||
|
self.assertEqual(doc.unverified_citation_count, 1)
|
||||||
|
|
||||||
|
# --- research loop ------------------------------------------------------
|
||||||
|
def test_15_research_loop_produces_ai_draft(self):
|
||||||
|
cites = [{"citation_text": "Doe v. Roe, 9 So.3d 9 (Fla. 2012)",
|
||||||
|
"proposition": "p"}]
|
||||||
|
with patch(CLIENT + "._call_provider",
|
||||||
|
return_value=_ai_response(citations=cites)), \
|
||||||
|
patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": True, "good_law": True}):
|
||||||
|
doc = self.env["familylaw.research.agent"].run(self.proc, "test question")
|
||||||
|
self.assertEqual(doc.state, "ai_draft")
|
||||||
|
self.assertEqual(len(doc.citation_ids), 1)
|
||||||
|
self.assertEqual(doc.citation_ids.status, "verified")
|
||||||
|
|
||||||
|
def test_16_research_loop_unverified_blocks_filing(self):
|
||||||
|
cites = [{"citation_text": "Fake v. Hallucination, 0 So.0d 0", "proposition": "p"}]
|
||||||
|
with patch(CLIENT + "._call_provider",
|
||||||
|
return_value=_ai_response(citations=cites)), \
|
||||||
|
patch(VERIFIER + "._verify_courtlistener",
|
||||||
|
return_value={"found": False, "good_law": False}), \
|
||||||
|
patch(VERIFIER + "._verify_fallback", return_value=None):
|
||||||
|
doc = self.env["familylaw.research.agent"].run(self.proc, "q")
|
||||||
|
self.assertEqual(doc.citation_ids.status, "not_found")
|
||||||
|
self._approve(doc)
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
doc.with_user(self.attorney).action_mark_filed()
|
||||||
@@ -89,6 +89,17 @@
|
|||||||
<field name="model">familylaw.citation</field>
|
<field name="model">familylaw.citation</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form string="Citation">
|
<form string="Citation">
|
||||||
|
<header>
|
||||||
|
<button name="action_verify" type="object"
|
||||||
|
string="Verify (CourtListener)" class="btn-primary"
|
||||||
|
invisible="status == 'verified'"/>
|
||||||
|
<button name="action_attorney_verify" type="object"
|
||||||
|
string="Attorney Attest"
|
||||||
|
invisible="status == 'verified'"
|
||||||
|
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||||
|
<button name="action_reject" type="object" string="Reject"
|
||||||
|
invisible="status in ('verified','rejected')"/>
|
||||||
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="alert alert-warning" role="alert"
|
<div class="alert alert-warning" role="alert"
|
||||||
invisible="status == 'verified'">
|
invisible="status == 'verified'">
|
||||||
@@ -107,6 +118,8 @@
|
|||||||
<field name="document_id"/>
|
<field name="document_id"/>
|
||||||
<field name="case_id" readonly="1"/>
|
<field name="case_id" readonly="1"/>
|
||||||
<field name="source_ai_task_id" readonly="1"/>
|
<field name="source_ai_task_id" readonly="1"/>
|
||||||
|
<field name="verification_source" readonly="1"/>
|
||||||
|
<field name="verification_date" readonly="1"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="Cited For"><field name="proposition" nolabel="1"/></group>
|
<group string="Cited For"><field name="proposition" nolabel="1"/></group>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
<button name="action_submit_for_review" type="object"
|
<button name="action_submit_for_review" type="object"
|
||||||
string="Submit for Review" class="btn-primary"
|
string="Submit for Review" class="btn-primary"
|
||||||
invisible="state not in ('ai_draft','rejected')"/>
|
invisible="state not in ('ai_draft','rejected')"/>
|
||||||
|
<button name="action_verify_citations" type="object"
|
||||||
|
string="Verify Citations"
|
||||||
|
invisible="unverified_citation_count == 0"/>
|
||||||
|
<button name="action_request_filing" type="object"
|
||||||
|
string="Request Filing"
|
||||||
|
invisible="state != 'approved'"/>
|
||||||
<button name="action_approve" type="object"
|
<button name="action_approve" type="object"
|
||||||
string="Approve" class="btn-primary"
|
string="Approve" class="btn-primary"
|
||||||
invisible="state != 'attorney_review'"
|
invisible="state != 'attorney_review'"
|
||||||
@@ -58,6 +64,12 @@
|
|||||||
This document is not yet approved. It cannot be filed or sent
|
This document is not yet approved. It cannot be filed or sent
|
||||||
until a licensed attorney approves it (the review gate).
|
until a licensed attorney approves it (the review gate).
|
||||||
</div>
|
</div>
|
||||||
|
<div class="alert alert-danger" role="alert"
|
||||||
|
invisible="unverified_citation_count == 0">
|
||||||
|
<field name="unverified_citation_count" readonly="1" nolabel="1"/>
|
||||||
|
unverified citation(s) — this document is BLOCKED from filing
|
||||||
|
until every citation is verified (the citation gate).
|
||||||
|
</div>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
<label for="name"/>
|
<label for="name"/>
|
||||||
<h1><field name="name" placeholder="e.g. Motion to Modify Child Support"/></h1>
|
<h1><field name="name" placeholder="e.g. Motion to Modify Child Support"/></h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user