← Back to DevBytes

Building AI Agents for Lawyers: Intake and Research

What Are AI Agents for Legal Intake and Research

AI agents for legal intake and research are autonomous software systems that use large language models (LLMs), retrieval-augmented generation (RAG), and workflow orchestration to handle two of the most time-intensive tasks in legal practice: onboarding new clients and conducting case law research. Unlike simple chatbots or document templates, these agents can reason over unstructured data, make decisions about classification and routing, extract structured information from messy documents, and perform multi-step research across legal databases — all while maintaining context, citing sources, and adhering to ethical guardrails.

A legal intake agent ingests prospective client communications (emails, form submissions, uploaded documents), classifies the matter type, extracts key parties, deadlines, and jurisdictional facts, flags conflicts, and routes the matter to the appropriate attorney or practice group. A legal research agent takes a natural language query — such as "find recent Ninth Circuit rulings on fair use in AI training datasets" — and performs semantic search across case law databases, retrieves relevant opinions, summarizes holdings, and generates a formatted research memo with pinpoint citations.

Together, these agents form the backbone of an AI-augmented law practice: they reduce administrative overhead by 60–80%, eliminate misfiled intake, and produce research deliverables in minutes rather than days. Critically, they do not replace attorney judgment — they amplify it by handling the mechanical grunt work so lawyers can focus on strategy, advocacy, and client counseling.

The Core Components

Every effective legal AI agent shares a common architectural skeleton:

Why It Matters for Law Firms

The economics are compelling. AmLaw 100 firms report that associates spend 30–40% of their time on intake triage and research — tasks that are necessary but do not require the full exercise of legal judgment. An AI agent handling intake can process 500+ matters per day with consistent accuracy, flagging high-value or emergency cases for immediate human attention. Research agents compress 6-hour manual research sessions into 3-minute query-to-memo workflows, while providing better coverage of obscure precedents that a rushed associate might miss.

Beyond efficiency, agents improve consistency: every intake is classified using the same rubric, every research memo follows the firm's citation style guide, and every step is logged for malpractice audit trails. For boutique firms and solo practitioners, these agents level the playing field, providing research depth and intake rigor that rivals large firm infrastructure at a fraction of the cost.

Building an AI Intake Agent

Architecture Overview

The intake agent follows a pipeline pattern. Raw input arrives via email or web form → the agent classifies the matter type → extracts structured entities → checks for conflicts → generates a standardized intake summary → routes to the responsible attorney and logs everything to the practice management system. Each stage is a discrete node in an orchestration graph, with conditional branching for edge cases (e.g., "is this a mass tort inquiry? Route to the mass tort group and flag for rapid response").

Setting Up the Environment

We will build a production-ready intake agent using Python, LangChain for the LLM interface, Pydantic for structured output validation, and a lightweight vector store for conflict checking. Install the dependencies:

# requirements.txt
langchain>=0.3.0
langchain-openai>=0.2.0
langgraph>=0.2.0
pydantic>=2.0.0
chromadb>=0.5.0
sqlalchemy>=2.0.0
python-dotenv>=1.0.0
fastapi>=0.115.0
uvicorn>=0.30.0
pip install -r requirements.txt

Set up your environment variables for API keys and database connections:

# .env
OPENAI_API_KEY=sk-your-key-here
DATABASE_URL=postgresql://user:pass@localhost:5432/legal_intake
FIRM_NAME=Smith & Associates LLP

Building the Intake Pipeline

We start by defining our structured data models. Pydantic gives us runtime validation and forces the LLM to conform to a strict schema — essential for legal workflows where a malformed extraction could mean a missed statute of limitations.

# models/intake_schema.py
from pydantic import BaseModel, Field, field_validator
from enum import Enum
from typing import Optional, List
from datetime import date

class MatterType(str, Enum):
    PERSONAL_INJURY = "personal_injury"
    FAMILY_LAW = "family_law"
    CORPORATE = "corporate"
    REAL_ESTATE = "real_estate"
    EMPLOYMENT = "employment"
    IP = "intellectual_property"
    CRIMINAL_DEFENSE = "criminal_defense"
    IMMIGRATION = "immigration"
    TAX = "tax"
    OTHER = "other"

class UrgencyLevel(str, Enum):
    CRITICAL = "critical"  # immediate attention required
    HIGH = "high"
    MEDIUM = "medium"
    LOW = "low"

class ExtractedEntity(BaseModel):
    """A key entity extracted from the intake communication."""
    entity_type: str  # person, organization, date, monetary_amount, case_reference
    value: str
    confidence: float = Field(ge=0.0, le=1.0)
    context_snippet: str

class IntakeSummary(BaseModel):
    """The fully structured output of the intake agent."""
    matter_type: MatterType
    matter_subtype: Optional[str] = None
    urgency: UrgencyLevel
    primary_client_name: str
    opposing_party_names: List[str] = Field(default_factory=list)
    jurisdiction: Optional[str] = None
    estimated_claim_value: Optional[float] = None
    critical_deadlines: List[date] = Field(default_factory=list)
    key_facts: List[str] = Field(min_length=1, max_length=10)
    extracted_entities: List[ExtractedEntity] = Field(default_factory=list)
    conflict_check_required: bool = True
    recommended_attorney: Optional[str] = None
    recommended_practice_group: Optional[str] = None
    summary_paragraph: str = Field(min_length=50, max_length=500)

    @field_validator('summary_paragraph')
    @classmethod
    def no_privileged_phrases(cls, v):
        forbidden = ['attorney-client privilege', 'work product', 'confidential per']
        for phrase in forbidden:
            if phrase.lower() in v.lower():
                # This is metadata, not the privilege itself
                pass
        return v

Next, we build the classification and extraction chain. We use LangChain's structured output parser with a carefully engineered prompt that includes firm-specific rubrics and jurisdictional triggers.

# chains/intake_extraction.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from models.intake_schema import IntakeSummary, MatterType, UrgencyLevel
import json

class IntakeExtractionChain:
    """Extracts structured intake data from unstructured client communications."""
    
    def __init__(self, model_name: str = "gpt-4o-mini", temperature: float = 0.1):
        self.llm = ChatOpenAI(
            model=model_name,
            temperature=temperature,
            max_tokens=2000
        )
        self.parser = PydanticOutputParser(pydantic_object=IntakeSummary)
        
    def build_prompt(self) -> ChatPromptTemplate:
        system_template = """You are an expert legal intake specialist at {firm_name}.
Your task is to analyze prospective client communications and produce a structured intake summary.

CLASSIFICATION RULES:
- PERSONAL_INJURY: auto accidents, slip-and-fall, medical malpractice, product liability
- FAMILY_LAW: divorce, custody, support, adoption, domestic violence
- CORPORATE: M&A, contract disputes, shareholder issues, business formation
- REAL_ESTATE: property disputes, landlord-tenant, zoning, eminent domain
- EMPLOYMENT: wrongful termination, discrimination, wage disputes, non-compete
- IP: patents, trademarks, copyrights, trade secrets, fair use
- CRIMINAL_DEFENSE: any criminal charge representation
- IMMIGRATION: visas, green cards, asylum, deportation defense
- TAX: IRS disputes, tax planning, audits
- OTHER: matters that do not fit the above

URGENCY RULES:
- CRITICAL: imminent court deadline (<5 days), arrest/detention, TRO hearing, statute of limitations <30 days
- HIGH: significant financial exposure, pending filing deadline, client in distress
- MEDIUM: routine matter, no immediate deadline pressure
- LOW: general inquiry, no active dispute

JURISDICTION DETECTION:
Extract the jurisdiction from mentions of courts, state law references, venue descriptions, 
or the client's location. Format as "State/Country - Court Level" when possible.

DEADLINE EXTRACTION:
Parse all dates mentioned. Convert relative dates ("next month", "two weeks from now") 
to absolute dates based on today: {current_date}.

CONFLICT INDICATORS:
Note any mention of: specific attorneys at the firm, prior consultations with the firm,
parties that might match existing clients, or matters against firm clients.

{format_instructions}

Return ONLY the structured JSON object. Do not include any preamble, explanation, or markdown."""
        
        return ChatPromptTemplate.from_messages([
            ("system", system_template),
            ("human", "{client_communication}")
        ])
    
    async def extract(self, client_communication: str, firm_name: str = "Smith & Associates LLP") -> IntakeSummary:
        prompt = self.build_prompt()
        formatted_prompt = prompt.format_messages(
            firm_name=firm_name,
            current_date="2025-01-15",  # In production: datetime.now().strftime("%Y-%m-%d")
            format_instructions=self.parser.get_format_instructions(),
            client_communication=client_communication
        )
        response = await self.llm.ainvoke(formatted_prompt)
        return self.parser.parse(response.content)

Code Example: Full Intake Agent with Conflict Checking

Now we assemble the complete intake agent. The key addition is the conflict-checking module, which embeds extracted party names and queries a vector database of existing firm clients. We use ChromaDB for simplicity, but in production you would point this at your firm's conflict database (iManage, NetDocuments, or a custom Postgres solution with pgvector).

# agents/intake_agent.py
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, List, Optional
from chains.intake_extraction import IntakeExtractionChain
from models.intake_schema import IntakeSummary, MatterType, UrgencyLevel
import chromadb
from chromadb.utils import embedding_functions
from datetime import datetime, timedelta
import hashlib
import logging

logger = logging.getLogger(__name__)

# --- State Definition ---
class IntakeState(TypedDict):
    """State that flows through the intake agent graph."""
    raw_input: str
    source: str  # e.g., 'email', 'web_form', 'phone_note'
    intake_summary: Optional[IntakeSummary]
    conflict_matches: List[dict]
    routing_decision: Optional[str]
    notification_sent: bool
    error_message: Optional[str]

# --- Conflict Checker Module ---
class ConflictChecker:
    """Checks prospective client parties against existing firm clients."""
    
    def __init__(self, persist_directory: str = "./conflict_db"):
        self.client = chromadb.PersistentClient(path=persist_directory)
        self.collection = self.client.get_or_create_collection(
            name="firm_clients",
            metadata={"description": "Existing firm clients for conflict checking"}
        )
        self.embedding_fn = embedding_functions.OpenAIEmbeddingFunction(
            api_key="your-openai-key",  # In production: os.getenv("OPENAI_API_KEY")
            model_name="text-embedding-3-small"
        )
    
    def add_client(self, client_name: str, metadata: dict):
        """Add a client to the conflict database."""
        doc_id = hashlib.md5(client_name.encode()).hexdigest()
        self.collection.add(
            ids=[doc_id],
            documents=[f"Client: {client_name} | Matter: {metadata.get('matter_type', 'N/A')} | Status: {metadata.get('status', 'active')}"],
            metadatas=[metadata]
        )
    
    def check(self, party_names: List[str], threshold: float = 0.85) -> List[dict]:
        """Check if any party names match existing firm clients."""
        if not party_names:
            return []
        
        results = []
        for name in party_names:
            query_results = self.collection.query(
                query_texts=[name],
                n_results=3,
                include=["documents", "metadatas", "distances"]
            )
            for i, distance in enumerate(query_results.get('distances', [[]])[0]):
                similarity = 1.0 - (distance / 2.0)  # Normalize distance to similarity
                if similarity >= threshold:
                    results.append({
                        "queried_party": name,
                        "matched_client": query_results['documents'][0][i],
                        "similarity_score": similarity,
                        "metadata": query_results['metadatas'][0][i]
                    })
        return results

# --- Routing Logic ---
class IntakeRouter:
    """Routes intake to the appropriate practice group and attorney."""
    
    PRACTICE_GROUPS = {
        MatterType.PERSONAL_INJURY: "Litigation - Personal Injury",
        MatterType.FAMILY_LAW: "Family Law Group",
        MatterType.CORPORATE: "Corporate & Business Group",
        MatterType.REAL_ESTATE: "Real Estate Group",
        MatterType.EMPLOYMENT: "Employment Law Group",
        MatterType.IP: "Intellectual Property Group",
        MatterType.CRIMINAL_DEFENSE: "Criminal Defense Group",
        MatterType.IMMIGRATION: "Immigration Practice",
        MatterType.TAX: "Tax Practice Group",
        MatterType.OTHER: "General Practice"
    }
    
    ATTORNEY_ASSIGNMENTS = {
        # In production, this would query an availability/load-balancing system
        MatterType.PERSONAL_INJURY: "Sarah Chen, Esq.",
        MatterType.FAMILY_LAW: "Michael Torres, Esq.",
        MatterType.CORPORATE: "David Kim, Esq.",
        MatterType.IP: "Priya Patel, Esq.",
        MatterType.EMPLOYMENT: "James Wilson, Esq.",
        MatterType.CRIMINAL_DEFENSE: "Robert Martinez, Esq.",
        MatterType.IMMIGRATION: "Angela Nguyen, Esq.",
        MatterType.TAX: "Jonathan Lee, CPA, Esq.",
        MatterType.REAL_ESTATE: "Emily Davis, Esq.",
        MatterType.OTHER: "Intake Coordinator"
    }
    
    def route(self, summary: IntakeSummary, conflict_matches: List[dict]) -> str:
        if conflict_matches:
            return "CONFLICT_HOLD"
        
        practice_group = self.PRACTICE_GROUPS.get(summary.matter_type, "General Practice")
        attorney = self.ATTORNEY_ASSIGNMENTS.get(summary.matter_type, "Intake Coordinator")
        
        if summary.urgency == UrgencyLevel.CRITICAL:
            return f"URGENT: {attorney} ({practice_group}) - Immediate callback required"
        
        return f"{attorney} ({practice_group})"

# --- The Full Agent Graph ---
class IntakeAgent:
    """Complete legal intake agent with classification, extraction, conflict check, and routing."""
    
    def __init__(self):
        self.extractor = IntakeExtractionChain()
        self.conflict_checker = ConflictChecker()
        self.router = IntakeRouter()
        self.graph = self._build_graph()
    
    def _build_graph(self) -> StateGraph:
        workflow = StateGraph(IntakeState)
        
        # Define nodes
        workflow.add_node("extract", self._extract_node)
        workflow.add_node("conflict_check", self._conflict_check_node)
        workflow.add_node("route", self._route_node)
        workflow.add_node("notify", self._notify_node)
        workflow.add_node("error_handler", self._error_node)
        
        # Define edges with conditional branching
        workflow.set_entry_point("extract")
        workflow.add_edge("extract", "conflict_check")
        workflow.add_conditional_edges(
            "conflict_check",
            self._after_conflict_check,
            {
                "route": "route",
                "conflict_hold": "notify",  # Notify that conflicts exist
                "error": "error_handler"
            }
        )
        workflow.add_edge("route", "notify")
        workflow.add_edge("notify", END)
        workflow.add_edge("error_handler", END)
        
        return workflow.compile()
    
    async def _extract_node(self, state: IntakeState) -> IntakeState:
        try:
            summary = await self.extractor.extract(state["raw_input"])
            state["intake_summary"] = summary
            state["error_message"] = None
            logger.info(f"Extracted matter type: {summary.matter_type}")
        except Exception as e:
            state["error_message"] = f"Extraction failed: {str(e)}"
            logger.error(state["error_message"])
        return state
    
    async def _conflict_check_node(self, state: IntakeState) -> IntakeState:
        if state.get("error_message"):
            return state
        
        summary = state.get("intake_summary")
        if not summary:
            state["error_message"] = "No intake summary available for conflict check"
            return state
        
        all_parties = [summary.primary_client_name] + summary.opposing_party_names
        matches = self.conflict_checker.check(all_parties)
        state["conflict_matches"] = matches
        
        if matches:
            logger.warning(f"Conflict detected: {len(matches)} matches found")
        
        return state
    
    def _after_conflict_check(self, state: IntakeState) -> str:
        if state.get("error_message"):
            return "error"
        if state.get("conflict_matches"):
            return "conflict_hold"
        return "route"
    
    async def _route_node(self, state: IntakeState) -> IntakeState:
        summary = state.get("intake_summary")
        if not summary:
            state["error_message"] = "Cannot route without intake summary"
            return state
        
        routing = self.router.route(summary, state.get("conflict_matches", []))
        state["routing_decision"] = routing
        return state
    
    async def _notify_node(self, state: IntakeState) -> IntakeState:
        """Send notification to the assigned attorney or conflict resolver."""
        routing = state.get("routing_decision", "Unrouted")
        conflicts = state.get("conflict_matches", [])
        
        # In production: send email, update CRM, post to Slack/webhook
        logger.info(f"Notification: Routing -> {routing}")
        if conflicts:
            logger.info(f"Conflict alert: {len(conflicts)} matches require manual review")
        
        state["notification_sent"] = True
        return state
    
    async def _error_node(self, state: IntakeState) -> IntakeState:
        logger.error(f"Intake agent error: {state.get('error_message')}")
        # In production: alert ops team, log to monitoring system
        return state
    
    async def process(self, raw_input: str, source: str = "web_form") -> dict:
        """Run the full intake pipeline and return the result."""
        initial_state: IntakeState = {
            "raw_input": raw_input,
            "source": source,
            "intake_summary": None,
            "conflict_matches": [],
            "routing_decision": None,
            "notification_sent": False,
            "error_message": None
        }
        
        final_state = await self.graph.ainvoke(initial_state)
        return {
            "intake_summary": final_state.get("intake_summary"),
            "conflict_matches": final_state.get("conflict_matches"),
            "routing_decision": final_state.get("routing_decision"),
            "notification_sent": final_state.get("notification_sent"),
            "error": final_state.get("error_message")
        }

To test the intake agent end-to-end, you can run it against sample client communications:

# test_intake.py
import asyncio
from agents.intake_agent import IntakeAgent

async def main():
    agent = IntakeAgent()
    
    # Sample client email
    sample_input = """
    Subject: Need legal help after car accident - insurance denying claim
    
    Hello, I was in a serious car accident on December 15th on I-5 near Seattle. 
    The other driver ran a red light and my car is totaled. I have severe back injuries.
    State Farm is denying my claim and saying it was 50/50 fault.
    I have medical bills over $50,000 and lost wages. The police report shows the 
    other driver was cited. I need to file a lawsuit quickly - I heard there's a 
    3-year statute of limitations in Washington but I want to act now.
    Please call me at 555-0123. My name is Robert Chen.
    """
    
    result = await agent.process(sample_input, source="email")
    
    if result["error"]:
        print(f"Error: {result['error']}")
    else:
        summary = result["intake_summary"]
        print(f"Matter Type: {summary.matter_type}")
        print(f"Urgency: {summary.urgency}")
        print(f"Client: {summary.primary_client_name}")
        print(f"Jurisdiction: {summary.jurisdiction}")
        print(f"Key Facts: {summary.key_facts}")
        print(f"Routing: {result['routing_decision']}")
        if result["conflict_matches"]:
            print(f"⚠️ CONFLICTS FOUND: {result['conflict_matches']}")

asyncio.run(main())

Building an AI Research Agent

Legal Research Architecture

The research agent follows a retrieval-augmented generation pattern with a crucial twist: legal research requires authoritative sources, not probabilistic generation. The agent must retrieve actual case law, statutes, or regulations before synthesizing an answer, and it must provide pinpoint citations that can be verified. Our architecture uses a multi-stage pipeline: query analysis → dense retrieval → reranking → source verification → synthesis with citations.

The query analysis stage decomposes the user's natural language question into structured search parameters (jurisdiction, date range, legal topic, procedural posture). The retrieval stage performs hybrid search (dense embeddings + keyword BM25) across a vectorized legal corpus. Reranking uses a cross-encoder model to push the most legally significant cases to the top. Finally, the synthesis stage generates a structured memo with holdings, reasoning, and Shepard's-style treatment notes.

Implementing Semantic Search Over Case Law

We build a legal research agent that uses the Caselaw Access Project (CAP) dataset — a free corpus of ~6.5 million U.S. court decisions. For production use with proprietary databases, the same patterns apply to Westlaw or LexisNexis API integrations. We embed the case law corpus into a vector database and expose a RAG interface.

# research/legal_vector_store.py
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
import json
from typing import List, Optional
import os

class LegalVectorStore:
    """Vector store populated with case law for semantic search."""
    
    def __init__(
        self,
        collection_name: str = "case_law_ninth_circuit",
        persist_directory: str = "./legal_vector_db",
        embedding_model: str = "text-embedding-3-large"
    ):
        self.embeddings = OpenAIEmbeddings(
            model=embedding_model,
            dimensions=1536
        )
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=1000,
            chunk_overlap=200,
            separators=["\n\n", "\n", ". ", " ", ""],
            keep_separator=True
        )
        self.vector_store = Chroma(
            collection_name=collection_name,
            embedding_function=self.embeddings,
            persist_directory=persist_directory
        )
    
    def index_opinion(self, opinion_json: dict) -> int:
        """Index a single judicial opinion into the vector store."""
        # Extract metadata
        metadata = {
            "case_name": opinion_json.get("name", "Unknown"),
            "citation": opinion_json.get("citation", ""),
            "court": opinion_json.get("court", ""),
            "jurisdiction": opinion_json.get("jurisdiction", ""),
            "date": opinion_json.get("decision_date", ""),
            "judges": opinion_json.get("judges", ""),
            "docket_number": opinion_json.get("docket_number", ""),
            "attorneys": opinion_json.get("attorneys", "")
        }
        
        # The opinion text may include headnotes, syllabus, and the body
        full_text = ""
        for section in ["headnotes", "syllabus", "opinion_text"]:
            if opinion_json.get(section):
                full_text += f"\n\n{opinion_json[section]}"
        
        # Split into chunks while preserving citation metadata on each chunk
        chunks = self.text_splitter.split_text(full_text)
        documents = [
            Document(
                page_content=chunk,
                metadata={**metadata, "chunk_index": i}
            )
            for i, chunk in enumerate(chunks)
        ]
        
        # Add to vector store
        ids = self.vector_store.add_documents(documents)
        return len(ids)
    
    def index_bulk(self, opinions: List[dict]):
        """Index multiple opinions. In production, use batch processing."""
        total = 0
        for opinion in opinions:
            try:
                count = self.index_opinion(opinion)
                total += count
            except Exception as e:
                print(f"Failed to index {opinion.get('name', 'Unknown')}: {e}")
        return total
    
    def semantic_search(
        self, 
        query: str, 
        k: int = 20,
        filter_court: Optional[str] = None,
        filter_jurisdiction: Optional[str] = None,
        date_range: Optional[tuple] = None  # (start_date, end_date)
    ) -> List[Document]:
        """Perform semantic search with optional metadata filters."""
        filter_dict = {}
        if filter_court:
            filter_dict["court"] = filter_court
        if filter_jurisdiction:
            filter_dict["jurisdiction"] = filter_jurisdiction
        
        # Chroma doesn't support date range natively, so we do post-filtering
        results = self.vector_store.similarity_search(
            query, 
            k=k * 2,  # Oversample to allow post-filtering
            filter=filter_dict if filter_dict else None
        )
        
        # Post-filter by date range if specified
        if date_range:
            start, end = date_range
            results = [
                doc for doc in results 
                if doc.metadata.get("date", "") >= start 
                and doc.metadata.get("date", "") <= end
            ]
        
        # Return top k after filtering
        return results[:k]

Code Example: Complete RAG Research Agent

Now we build the full research agent with query decomposition, retrieval, reranking, and memo generation. The agent uses a cross-encoder for reranking (critical for legal search where the top embedding hit may not be the most legally significant case) and generates a structured research memo with proper Bluebook citations.

# agents/research_agent.py
from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Optional, Dict, Any
from research.legal_vector_store import LegalVectorStore
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
import asyncio
import logging

logger = logging.getLogger(__name__)

# --- State ---
class ResearchState(TypedDict):
    """State flowing through the research agent."""
    user_query: str
    decomposed_queries: List[str]
    jurisdiction_filter: Optional[str]
    date_range: Optional[Dict[str, str]]
    retrieved_documents: List[Any]
    reranked_documents: List[Any]
    verified_sources: List[Dict]
    research_memo: Optional[str]
    citations: List[str]
    error_message: Optional[str]

# --- Query Analyzer ---
class QueryAnalyzer:
    """Decomposes natural language legal queries into structured search parameters."""
    
    def __init__(self, model_name: str = "gpt-4o-mini"):
        self.llm = ChatOpenAI(model=model_name, temperature=0.0)
    
    async def analyze(self, query: str) -> Dict[str, Any]:
        prompt = ChatPromptTemplate.from_messages([
            ("system", """You are a legal research query analyzer. Given a natural language 
legal research question, extract structured parameters.

Return a JSON object with these fields:
{
  "decomposed_queries": ["list", "of", "specific", "search", "queries"],
  "jurisdiction_filter": "circuit or state name or null",
  "date_range": {"start": "YYYY-MM-DD or null", "end": "YYYY-MM-DD or null"},
  "legal_topics": ["list", "of", "key", "legal", "concepts"],
  "procedural_posture": "appeal, trial, motion to dismiss, etc. or null",
  "requested_treatment": "shepardize, distinguish, find analogous, or null"
}

Decompose the query into 3-5 specific search queries that would retrieve relevant cases.
For jurisdiction: extract mentions of federal circuits, states, districts, or courts.
For date range: if the user specifies "recent", use last 2 years. If "landmark", leave open.
If the user mentions a specific case name, add a query for that exact case."""),
            ("human", "{query}")
        ])
        
        response = await self.llm.ainvoke(prompt.format_messages(query=query))
        # Parse the JSON response
        import json
        try:
            return json.loads(response.content)
        except json.JSONDecodeError:
            # Fallback: treat the whole query as a single search
            return {
                "decomposed_queries": [query],
                "jurisdiction_filter": None,
                "date_range": {"start": None, "end": None},
                "legal_topics": [],
                "procedural_posture": None,
                "requested_treatment": None
            }

# --- Reranker (Cross-Encoder) ---
class LegalReranker:
    """Reranks retrieved documents using a legal-specific cross-encoder."""
    
    def __init__(self):
        # In production, use a fine-tuned legal reranker or an API
        # For demonstration, we use the LLM as a relevance scorer
        self.llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.0)
    
    async def rerank(self, query: str, documents: List[Any], top_k: int = 10) -> List[Any]:
        """Score and rerank documents by legal relevance to the query."""
        if len(documents) <= top_k:
            return documents
        
        # Batch scoring: evaluate documents in groups
        scored_docs = []
        batch_size = 5
        
        for i in range(0, len(documents), batch_size):
            batch = documents[i:i+batch_size]
            
            # Create a scoring prompt
            docs_text = "\n---\n".join([
                f"Document {j}: {doc.page_content[:500]}...\nCitation: {doc.metadata.get('citation', 'N/A')}"
                for j, doc in enumerate(batch)
            ])
            
            prompt = ChatPromptTemplate.from_messages([
                ("system", """You are scoring legal documents for relevance to a research query.
Rate each document from 0 to 10 based on:
- Direct legal relevance to the query
- Authoritativeness (higher court, published opinion > unpublished)
- Factual similarity to the scenario described
- Precedential value (landmark cases, often-cited cases score higher)

Return scores as: Document 0: X, Document 1: Y, etc. Only return the scores."""),
                ("human", "Query: {query}\n\nDocuments:\n{docs_text}\n\nScores:")
            ])
            
            response = await self.llm.ainvoke(prompt.format_messages(
                query=query,
                docs_text=docs_text
            ))
            
            # Parse scores (simplified; production code would be more robust)
            for j, doc in enumerate(batch):
                try:
                    score_line = [line for line in response.content

— Ad —

Google AdSense will appear here after approval

← Back to all articles