feat: implement AI legal assistant system MVP
Core modules: - Laws: CRUD, search, AI-powered QA - Analysis: legal research and case management - Contracts: lifecycle management with templates - Signatures: electronic signature workflow Infrastructure: - FastAPI + SQLite + async SQLAlchemy - Docker deployment support - 54 unit tests passing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
830b33c4b3
commit
656f596d7e
21
.env.example
Normal file
21
.env.example
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# API Keys
|
||||||
|
LLM_API_KEY=your-api-key-here
|
||||||
|
LLM_API_BASE=https://api.openai.com/v1
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
EMBEDDING_API_KEY=your-api-key-here
|
||||||
|
EMBEDDING_API_BASE=https://api.openai.com/v1
|
||||||
|
EMBEDDING_MODEL=text-embedding-3-small
|
||||||
|
EMBEDDING_DIMENSION=1536
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./data/legal_assistant.db
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
|
||||||
|
# Application
|
||||||
|
DEBUG=false
|
||||||
|
UPLOAD_DIR=./uploads
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -39,3 +39,4 @@ QWEN.md
|
|||||||
.codex/
|
.codex/
|
||||||
.gemini/
|
.gemini/
|
||||||
.qwen/
|
.qwen/
|
||||||
|
venv/
|
||||||
|
|||||||
24
backend/Dockerfile
Normal file
24
backend/Dockerfile
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data /app/uploads
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Backend package
|
||||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API v1 package
|
||||||
1
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# API v1 endpoints
|
||||||
140
backend/app/api/v1/analyses.py
Normal file
140
backend/app/api/v1/analyses.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""Analysis API endpoints."""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas.analysis import (
|
||||||
|
LegalAnalysisCreate,
|
||||||
|
LegalAnalysisUpdate,
|
||||||
|
LegalAnalysisResponse,
|
||||||
|
CaseCreate,
|
||||||
|
CaseResponse,
|
||||||
|
GenerateAnalysisRequest,
|
||||||
|
GenerateAnalysisResponse,
|
||||||
|
)
|
||||||
|
from app.services.analysis_service import AnalysisService, CaseService
|
||||||
|
from app.services.llm_service import llm_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analyses", tags=["analyses"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=LegalAnalysisResponse)
|
||||||
|
async def create_analysis(
|
||||||
|
analysis_data: LegalAnalysisCreate,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new legal analysis."""
|
||||||
|
service = AnalysisService(db)
|
||||||
|
analysis = await service.create_analysis(
|
||||||
|
user_id=user_id,
|
||||||
|
title=analysis_data.title,
|
||||||
|
case_description=analysis_data.case_description,
|
||||||
|
legal_basis=analysis_data.legal_basis,
|
||||||
|
)
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[LegalAnalysisResponse])
|
||||||
|
async def list_analyses(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List analyses for current user."""
|
||||||
|
service = AnalysisService(db)
|
||||||
|
analyses = await service.get_analyses_by_user(user_id, skip, limit)
|
||||||
|
return analyses
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{analysis_id}", response_model=LegalAnalysisResponse)
|
||||||
|
async def get_analysis(
|
||||||
|
analysis_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get an analysis by ID."""
|
||||||
|
service = AnalysisService(db)
|
||||||
|
analysis = await service.get_analysis_by_id(analysis_id)
|
||||||
|
|
||||||
|
if not analysis:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{analysis_id}", response_model=LegalAnalysisResponse)
|
||||||
|
async def update_analysis(
|
||||||
|
analysis_id: int,
|
||||||
|
analysis_data: LegalAnalysisUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an analysis."""
|
||||||
|
service = AnalysisService(db)
|
||||||
|
analysis = await service.update_analysis(
|
||||||
|
analysis_id,
|
||||||
|
**analysis_data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not analysis:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{analysis_id}")
|
||||||
|
async def delete_analysis(
|
||||||
|
analysis_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete an analysis."""
|
||||||
|
service = AnalysisService(db)
|
||||||
|
success = await service.delete_analysis(analysis_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Analysis not found")
|
||||||
|
|
||||||
|
return {"message": "Analysis deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=GenerateAnalysisResponse)
|
||||||
|
async def generate_analysis(
|
||||||
|
request: GenerateAnalysisRequest,
|
||||||
|
):
|
||||||
|
"""Generate legal analysis using AI."""
|
||||||
|
result = await llm_service.analyze_legal_issue(
|
||||||
|
issue_description=request.case_description,
|
||||||
|
relevant_laws=request.relevant_laws,
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenerateAnalysisResponse(
|
||||||
|
analysis_content=result,
|
||||||
|
legal_basis=request.relevant_laws or [],
|
||||||
|
conclusion=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Case endpoints
|
||||||
|
@router.post("/cases", response_model=CaseResponse)
|
||||||
|
async def create_case(
|
||||||
|
case_data: CaseCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new case."""
|
||||||
|
service = CaseService(db)
|
||||||
|
case = await service.create_case(**case_data.model_dump())
|
||||||
|
return case
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cases", response_model=list[CaseResponse])
|
||||||
|
async def list_cases(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
case_type: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List cases."""
|
||||||
|
service = CaseService(db)
|
||||||
|
cases = await service.get_cases_list(skip, limit, case_type)
|
||||||
|
return cases
|
||||||
180
backend/app/api/v1/contracts.py
Normal file
180
backend/app/api/v1/contracts.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
"""Contract API endpoints."""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.contract import ContractStatus
|
||||||
|
from app.schemas.contract import (
|
||||||
|
ContractCreate,
|
||||||
|
ContractUpdate,
|
||||||
|
ContractResponse,
|
||||||
|
ContractTemplateCreate,
|
||||||
|
ContractTemplateResponse,
|
||||||
|
ApprovalRequest,
|
||||||
|
ApprovalResponse,
|
||||||
|
ContractReviewRequest,
|
||||||
|
ContractReviewResponse,
|
||||||
|
)
|
||||||
|
from app.services.contract_service import ContractService, ContractTemplateService
|
||||||
|
from app.services.llm_service import llm_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contracts", tags=["contracts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ContractResponse)
|
||||||
|
async def create_contract(
|
||||||
|
contract_data: ContractCreate,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new contract."""
|
||||||
|
service = ContractService(db)
|
||||||
|
contract = await service.create_contract(
|
||||||
|
created_by=user_id,
|
||||||
|
**contract_data.model_dump(exclude={"template_id"}),
|
||||||
|
template_id=contract_data.template_id,
|
||||||
|
)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ContractResponse])
|
||||||
|
async def list_contracts(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List contracts."""
|
||||||
|
service = ContractService(db)
|
||||||
|
status_filter = ContractStatus(status) if status else None
|
||||||
|
contracts = await service.get_contracts_list(skip, limit, status_filter, user_id)
|
||||||
|
return contracts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{contract_id}", response_model=ContractResponse)
|
||||||
|
async def get_contract(
|
||||||
|
contract_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a contract by ID."""
|
||||||
|
service = ContractService(db)
|
||||||
|
contract = await service.get_contract_by_id(contract_id)
|
||||||
|
|
||||||
|
if not contract:
|
||||||
|
raise HTTPException(status_code=404, detail="Contract not found")
|
||||||
|
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{contract_id}", response_model=ContractResponse)
|
||||||
|
async def update_contract(
|
||||||
|
contract_id: int,
|
||||||
|
contract_data: ContractUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a contract."""
|
||||||
|
service = ContractService(db)
|
||||||
|
contract = await service.update_contract(
|
||||||
|
contract_id,
|
||||||
|
**contract_data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not contract:
|
||||||
|
raise HTTPException(status_code=404, detail="Contract not found")
|
||||||
|
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{contract_id}/submit", response_model=ContractResponse)
|
||||||
|
async def submit_contract(
|
||||||
|
contract_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Submit a contract for approval."""
|
||||||
|
service = ContractService(db)
|
||||||
|
contract = await service.submit_for_approval(contract_id)
|
||||||
|
|
||||||
|
if not contract:
|
||||||
|
raise HTTPException(status_code=404, detail="Contract not found")
|
||||||
|
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{contract_id}/approve", response_model=ApprovalResponse)
|
||||||
|
async def approve_contract(
|
||||||
|
contract_id: int,
|
||||||
|
approval_data: ApprovalRequest,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Approve a contract."""
|
||||||
|
service = ContractService(db)
|
||||||
|
approval = await service.approve_contract(
|
||||||
|
contract_id,
|
||||||
|
user_id,
|
||||||
|
approval_data.comment,
|
||||||
|
)
|
||||||
|
return approval
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{contract_id}/reject", response_model=ApprovalResponse)
|
||||||
|
async def reject_contract(
|
||||||
|
contract_id: int,
|
||||||
|
approval_data: ApprovalRequest,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Reject a contract."""
|
||||||
|
service = ContractService(db)
|
||||||
|
approval = await service.reject_contract(
|
||||||
|
contract_id,
|
||||||
|
user_id,
|
||||||
|
approval_data.comment,
|
||||||
|
)
|
||||||
|
return approval
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/review", response_model=ContractReviewResponse)
|
||||||
|
async def review_contract(
|
||||||
|
review_data: ContractReviewRequest,
|
||||||
|
):
|
||||||
|
"""Review a contract using AI."""
|
||||||
|
result = await llm_service.review_contract(
|
||||||
|
contract_content=review_data.contract_content,
|
||||||
|
contract_type=review_data.contract_type,
|
||||||
|
)
|
||||||
|
return ContractReviewResponse(
|
||||||
|
review_result=result,
|
||||||
|
risks=[],
|
||||||
|
suggestions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Template endpoints
|
||||||
|
@router.post("/templates", response_model=ContractTemplateResponse)
|
||||||
|
async def create_template(
|
||||||
|
template_data: ContractTemplateCreate,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a contract template."""
|
||||||
|
service = ContractTemplateService(db)
|
||||||
|
template = await service.create_template(
|
||||||
|
created_by=user_id,
|
||||||
|
**template_data.model_dump(),
|
||||||
|
)
|
||||||
|
return template
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=list[ContractTemplateResponse])
|
||||||
|
async def list_templates(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List contract templates."""
|
||||||
|
service = ContractTemplateService(db)
|
||||||
|
templates = await service.get_templates_list(skip, limit)
|
||||||
|
return templates
|
||||||
184
backend/app/api/v1/laws.py
Normal file
184
backend/app/api/v1/laws.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""Law API endpoints."""
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.law import LawType, LawStatus
|
||||||
|
from app.schemas.law import (
|
||||||
|
LawCreate,
|
||||||
|
LawUpdate,
|
||||||
|
LawResponse,
|
||||||
|
LawListResponse,
|
||||||
|
LawArticleCreate,
|
||||||
|
LawArticleResponse,
|
||||||
|
LawSearchRequest,
|
||||||
|
LegalQARequest,
|
||||||
|
LegalQAResponse,
|
||||||
|
)
|
||||||
|
from app.services.law_service import LawService
|
||||||
|
from app.services.llm_service import llm_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/laws", tags=["laws"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=LawResponse)
|
||||||
|
async def create_law(
|
||||||
|
law_data: LawCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new law."""
|
||||||
|
service = LawService(db)
|
||||||
|
law = await service.create_law(
|
||||||
|
title=law_data.title,
|
||||||
|
law_type=LawType(law_data.law_type.value),
|
||||||
|
promulgation_date=law_data.promulgation_date,
|
||||||
|
effective_date=law_data.effective_date,
|
||||||
|
issuing_authority=law_data.issuing_authority,
|
||||||
|
content=law_data.content,
|
||||||
|
status=LawStatus(law_data.status.value),
|
||||||
|
document_number=law_data.document_number,
|
||||||
|
)
|
||||||
|
return law
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=LawListResponse)
|
||||||
|
async def list_laws(
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
law_type: Optional[str] = Query(None),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List laws with optional filters."""
|
||||||
|
service = LawService(db)
|
||||||
|
|
||||||
|
law_type_filter = LawType(law_type) if law_type else None
|
||||||
|
status_filter = LawStatus(status) if status else None
|
||||||
|
|
||||||
|
laws = await service.get_laws_list(
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
law_type=law_type_filter,
|
||||||
|
status=status_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LawListResponse(
|
||||||
|
items=laws,
|
||||||
|
total=len(laws),
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{law_id}", response_model=LawResponse)
|
||||||
|
async def get_law(
|
||||||
|
law_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a law by ID."""
|
||||||
|
service = LawService(db)
|
||||||
|
law = await service.get_law_by_id(law_id)
|
||||||
|
|
||||||
|
if not law:
|
||||||
|
raise HTTPException(status_code=404, detail="Law not found")
|
||||||
|
|
||||||
|
return law
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{law_id}", response_model=LawResponse)
|
||||||
|
async def update_law(
|
||||||
|
law_id: int,
|
||||||
|
law_data: LawUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a law."""
|
||||||
|
service = LawService(db)
|
||||||
|
|
||||||
|
update_dict = law_data.model_dump(exclude_unset=True)
|
||||||
|
if "law_type" in update_dict:
|
||||||
|
update_dict["law_type"] = LawType(update_dict["law_type"].value)
|
||||||
|
if "status" in update_dict:
|
||||||
|
update_dict["status"] = LawStatus(update_dict["status"].value)
|
||||||
|
|
||||||
|
law = await service.update_law(law_id, **update_dict)
|
||||||
|
|
||||||
|
if not law:
|
||||||
|
raise HTTPException(status_code=404, detail="Law not found")
|
||||||
|
|
||||||
|
return law
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{law_id}")
|
||||||
|
async def delete_law(
|
||||||
|
law_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a law."""
|
||||||
|
service = LawService(db)
|
||||||
|
success = await service.delete_law(law_id)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Law not found")
|
||||||
|
|
||||||
|
return {"message": "Law deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search", response_model=LawListResponse)
|
||||||
|
async def search_laws(
|
||||||
|
search_data: LawSearchRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Search laws by keyword."""
|
||||||
|
service = LawService(db)
|
||||||
|
laws = await service.search_laws_by_keyword(
|
||||||
|
keyword=search_data.keyword,
|
||||||
|
limit=search_data.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LawListResponse(
|
||||||
|
items=laws,
|
||||||
|
total=len(laws),
|
||||||
|
skip=0,
|
||||||
|
limit=search_data.limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/qa", response_model=LegalQAResponse)
|
||||||
|
async def legal_qa(
|
||||||
|
qa_data: LegalQARequest,
|
||||||
|
):
|
||||||
|
"""Ask a legal question and get AI-powered answer."""
|
||||||
|
answer = await llm_service.legal_qa(
|
||||||
|
question=qa_data.question,
|
||||||
|
context=qa_data.context,
|
||||||
|
)
|
||||||
|
|
||||||
|
return LegalQAResponse(
|
||||||
|
question=qa_data.question,
|
||||||
|
answer=answer,
|
||||||
|
references=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{law_id}/articles", response_model=LawArticleResponse)
|
||||||
|
async def create_article(
|
||||||
|
law_id: int,
|
||||||
|
article_data: LawArticleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a law article."""
|
||||||
|
service = LawService(db)
|
||||||
|
|
||||||
|
# Verify law exists
|
||||||
|
law = await service.get_law_by_id(law_id)
|
||||||
|
if not law:
|
||||||
|
raise HTTPException(status_code=404, detail="Law not found")
|
||||||
|
|
||||||
|
article = await service.create_article(
|
||||||
|
law_id=law_id,
|
||||||
|
article_number=article_data.article_number,
|
||||||
|
content=article_data.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
return article
|
||||||
96
backend/app/api/v1/signatures.py
Normal file
96
backend/app/api/v1/signatures.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Signature API endpoints."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.schemas.signature import (
|
||||||
|
SignatureRequestCreate,
|
||||||
|
SignatureRequestResponse,
|
||||||
|
SignatureSignRequest,
|
||||||
|
SignatureResponse,
|
||||||
|
SignatureVerifyResponse,
|
||||||
|
)
|
||||||
|
from app.services.signature_service import SignatureService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/signatures", tags=["signatures"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request", response_model=SignatureRequestResponse)
|
||||||
|
async def create_signature_request(
|
||||||
|
request_data: SignatureRequestCreate,
|
||||||
|
user_id: int = 1, # TODO: Get from auth
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a signature request."""
|
||||||
|
service = SignatureService(db)
|
||||||
|
sig_request = await service.create_signature_request(
|
||||||
|
contract_id=request_data.contract_id,
|
||||||
|
requester_id=user_id,
|
||||||
|
signer_name=request_data.signer_name,
|
||||||
|
signer_email=request_data.signer_email,
|
||||||
|
expire_hours=request_data.expire_hours,
|
||||||
|
)
|
||||||
|
return sig_request
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{token}", response_model=SignatureRequestResponse)
|
||||||
|
async def get_signature_request(
|
||||||
|
token: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a signature request by token."""
|
||||||
|
service = SignatureService(db)
|
||||||
|
sig_request = await service.get_signature_request_by_token(token)
|
||||||
|
|
||||||
|
if not sig_request:
|
||||||
|
raise HTTPException(status_code=404, detail="Signature request not found")
|
||||||
|
|
||||||
|
return sig_request
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{token}/sign", response_model=SignatureResponse)
|
||||||
|
async def sign_document(
|
||||||
|
token: str,
|
||||||
|
sign_data: SignatureSignRequest,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sign a document."""
|
||||||
|
service = SignatureService(db)
|
||||||
|
sig_request = await service.get_signature_request_by_token(token)
|
||||||
|
|
||||||
|
if not sig_request:
|
||||||
|
raise HTTPException(status_code=404, detail="Signature request not found")
|
||||||
|
|
||||||
|
if not await service.is_request_valid(sig_request):
|
||||||
|
raise HTTPException(status_code=400, detail="Signature request is not valid")
|
||||||
|
|
||||||
|
# Get client info
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent")
|
||||||
|
|
||||||
|
signature = await service.sign_document(
|
||||||
|
request=sig_request,
|
||||||
|
signature_data=sign_data.signature_data,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
return signature
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{signature_id}/verify", response_model=SignatureVerifyResponse)
|
||||||
|
async def verify_signature(
|
||||||
|
signature_id: int,
|
||||||
|
content_hash: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Verify a signature."""
|
||||||
|
service = SignatureService(db)
|
||||||
|
# Note: In production, you would verify against stored content
|
||||||
|
# This is a simplified version
|
||||||
|
return SignatureVerifyResponse(
|
||||||
|
valid=True,
|
||||||
|
signed_at=None,
|
||||||
|
signer_name=None,
|
||||||
|
)
|
||||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Core package
|
||||||
47
backend/app/core/config.py
Normal file
47
backend/app/core/config.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""Core application configuration."""
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Application settings."""
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME: str = "AI Legal Assistant"
|
||||||
|
APP_VERSION: str = "1.0.0"
|
||||||
|
DEBUG: bool = False
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "sqlite+aiosqlite:///./data/legal_assistant.db"
|
||||||
|
|
||||||
|
# LLM Configuration
|
||||||
|
LLM_API_KEY: Optional[str] = None
|
||||||
|
LLM_API_BASE: str = "https://api.openai.com/v1"
|
||||||
|
LLM_MODEL: str = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# Embedding Configuration
|
||||||
|
EMBEDDING_API_KEY: Optional[str] = None
|
||||||
|
EMBEDDING_API_BASE: str = "https://api.openai.com/v1"
|
||||||
|
EMBEDDING_MODEL: str = "text-embedding-3-small"
|
||||||
|
EMBEDDING_DIMENSION: int = 1536
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
UPLOAD_DIR: str = "./uploads"
|
||||||
|
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||||
|
|
||||||
|
# Signature
|
||||||
|
SIGNATURE_TOKEN_EXPIRE_HOURS: int = 72 # 3 days
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
47
backend/app/core/database.py
Normal file
47
backend/app/core/database.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"""Database configuration and session management."""
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
create_async_engine,
|
||||||
|
async_sessionmaker,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all database models."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Create async engine
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
echo=settings.DEBUG,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create async session factory
|
||||||
|
async_session_maker = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Dependency for getting async database sessions."""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
"""Initialize database tables."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
61
backend/app/core/security.py
Normal file
61
backend/app/core/security.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""Security utilities for authentication and authorization."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""Hash a password using bcrypt."""
|
||||||
|
salt = bcrypt.gensalt()
|
||||||
|
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||||
|
return hashed.decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""Verify a password against its hash."""
|
||||||
|
return bcrypt.checkpw(
|
||||||
|
plain_password.encode('utf-8'),
|
||||||
|
hashed_password.encode('utf-8')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
expires_delta: Optional[timedelta] = None
|
||||||
|
) -> str:
|
||||||
|
"""Create a JWT access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(
|
||||||
|
minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
|
||||||
|
encoded_jwt = jwt.encode(
|
||||||
|
to_encode,
|
||||||
|
settings.JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.JWT_ALGORITHM
|
||||||
|
)
|
||||||
|
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Decode and validate a JWT access token."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.JWT_ALGORITHM]
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
56
backend/app/main.py
Normal file
56
backend/app/main.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""Main FastAPI application."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import init_db
|
||||||
|
from app.api.v1 import laws, analyses, contracts, signatures
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Application lifespan handler."""
|
||||||
|
# Startup
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.APP_NAME,
|
||||||
|
version=settings.APP_VERSION,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # Configure in production
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include routers
|
||||||
|
app.include_router(laws.router, prefix="/api/v1")
|
||||||
|
app.include_router(analyses.router, prefix="/api/v1")
|
||||||
|
app.include_router(contracts.router, prefix="/api/v1")
|
||||||
|
app.include_router(signatures.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint."""
|
||||||
|
return {
|
||||||
|
"name": settings.APP_NAME,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "healthy"}
|
||||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Models package
|
||||||
96
backend/app/models/analysis.py
Normal file
96
backend/app/models/analysis.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Analysis model for legal research."""
|
||||||
|
import enum
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Date, DateTime, Enum as SQLEnum, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisStatus(str, enum.Enum):
|
||||||
|
"""Analysis status enumeration."""
|
||||||
|
DRAFT = "draft"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class LegalAnalysis(Base):
|
||||||
|
"""Legal analysis model."""
|
||||||
|
|
||||||
|
__tablename__ = "legal_analyses"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
case_description: Mapped[str] = mapped_column(Text)
|
||||||
|
legal_basis: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
analysis_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
conclusion: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[AnalysisStatus] = mapped_column(
|
||||||
|
SQLEnum(AnalysisStatus),
|
||||||
|
default=AnalysisStatus.DRAFT
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
case_description: str,
|
||||||
|
legal_basis: Optional[dict] = None,
|
||||||
|
analysis_content: Optional[str] = None,
|
||||||
|
conclusion: Optional[str] = None,
|
||||||
|
status: AnalysisStatus = AnalysisStatus.DRAFT,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.user_id = user_id
|
||||||
|
self.title = title
|
||||||
|
self.case_description = case_description
|
||||||
|
self.legal_basis = legal_basis
|
||||||
|
self.analysis_content = analysis_content
|
||||||
|
self.conclusion = conclusion
|
||||||
|
self.status = status
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class Case(Base):
|
||||||
|
"""Case model for case database."""
|
||||||
|
|
||||||
|
__tablename__ = "cases"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), index=True)
|
||||||
|
case_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
court: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
case_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
judgment_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
facts: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
judgment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
reasoning: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
case_number: Optional[str] = None,
|
||||||
|
court: Optional[str] = None,
|
||||||
|
case_type: Optional[str] = None,
|
||||||
|
judgment_date: Optional[date] = None,
|
||||||
|
facts: Optional[str] = None,
|
||||||
|
judgment: Optional[str] = None,
|
||||||
|
reasoning: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self.case_number = case_number
|
||||||
|
self.court = court
|
||||||
|
self.case_type = case_type
|
||||||
|
self.judgment_date = judgment_date
|
||||||
|
self.facts = facts
|
||||||
|
self.judgment = judgment
|
||||||
|
self.reasoning = reasoning
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
60
backend/app/models/case.py
Normal file
60
backend/app/models/case.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""Case review model."""
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, Enum as SQLEnum, Integer, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewStatus(str, enum.Enum):
|
||||||
|
"""Review status enumeration."""
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewType(str, enum.Enum):
|
||||||
|
"""Review type enumeration."""
|
||||||
|
INITIAL = "initial"
|
||||||
|
SECONDARY = "secondary"
|
||||||
|
FINAL = "final"
|
||||||
|
|
||||||
|
|
||||||
|
class CaseReview(Base):
|
||||||
|
"""Case review model."""
|
||||||
|
|
||||||
|
__tablename__ = "case_reviews"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
case_id: Mapped[int] = mapped_column(Integer, ForeignKey("cases.id"), index=True)
|
||||||
|
reviewer_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
|
||||||
|
review_type: Mapped[ReviewType] = mapped_column(SQLEnum(ReviewType))
|
||||||
|
opinion: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
status: Mapped[ReviewStatus] = mapped_column(
|
||||||
|
SQLEnum(ReviewStatus),
|
||||||
|
default=ReviewStatus.PENDING
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
reviewed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
case_id: int,
|
||||||
|
reviewer_id: int,
|
||||||
|
review_type: ReviewType,
|
||||||
|
opinion: Optional[str] = None,
|
||||||
|
score: Optional[int] = None,
|
||||||
|
status: ReviewStatus = ReviewStatus.PENDING,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.case_id = case_id
|
||||||
|
self.reviewer_id = reviewer_id
|
||||||
|
self.review_type = review_type
|
||||||
|
self.opinion = opinion
|
||||||
|
self.score = score
|
||||||
|
self.status = status
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
131
backend/app/models/contract.py
Normal file
131
backend/app/models/contract.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""Contract model."""
|
||||||
|
import enum
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Date, DateTime, Enum as SQLEnum, Integer, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ContractStatus(str, enum.Enum):
|
||||||
|
"""Contract status enumeration."""
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
APPROVED = "approved"
|
||||||
|
PENDING_SIGNATURE = "pending_signature"
|
||||||
|
SIGNED = "signed"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class Contract(Base):
|
||||||
|
"""Contract model."""
|
||||||
|
|
||||||
|
__tablename__ = "contracts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
template_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("contract_templates.id"), nullable=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), index=True)
|
||||||
|
contract_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
party_a: Mapped[str] = mapped_column(String(100))
|
||||||
|
party_b: Mapped[str] = mapped_column(String(100))
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
status: Mapped[ContractStatus] = mapped_column(
|
||||||
|
SQLEnum(ContractStatus),
|
||||||
|
default=ContractStatus.DRAFT
|
||||||
|
)
|
||||||
|
effective_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
expiry_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
|
||||||
|
file_path: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
party_a: str,
|
||||||
|
party_b: str,
|
||||||
|
content: str,
|
||||||
|
created_by: int,
|
||||||
|
template_id: Optional[int] = None,
|
||||||
|
contract_number: Optional[str] = None,
|
||||||
|
status: ContractStatus = ContractStatus.DRAFT,
|
||||||
|
effective_date: Optional[date] = None,
|
||||||
|
expiry_date: Optional[date] = None,
|
||||||
|
file_path: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self.party_a = party_a
|
||||||
|
self.party_b = party_b
|
||||||
|
self.content = content
|
||||||
|
self.created_by = created_by
|
||||||
|
self.template_id = template_id
|
||||||
|
self.contract_number = contract_number
|
||||||
|
self.status = status
|
||||||
|
self.effective_date = effective_date
|
||||||
|
self.expiry_date = expiry_date
|
||||||
|
self.file_path = file_path
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplate(Base):
|
||||||
|
"""Contract template model."""
|
||||||
|
|
||||||
|
__tablename__ = "contract_templates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
variables: Mapped[Optional[dict]] = mapped_column(String, nullable=True) # JSON string
|
||||||
|
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
created_by: int,
|
||||||
|
contract_type: Optional[str] = None,
|
||||||
|
variables: Optional[dict] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.content = content
|
||||||
|
self.created_by = created_by
|
||||||
|
self.contract_type = contract_type
|
||||||
|
self.variables = variables
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class ContractApproval(Base):
|
||||||
|
"""Contract approval model."""
|
||||||
|
|
||||||
|
__tablename__ = "contract_approvals"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
contract_id: Mapped[int] = mapped_column(Integer, ForeignKey("contracts.id"), index=True)
|
||||||
|
approver_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
|
||||||
|
status: Mapped[ContractStatus] = mapped_column(SQLEnum(ContractStatus))
|
||||||
|
comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
approver_id: int,
|
||||||
|
status: ContractStatus,
|
||||||
|
comment: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.contract_id = contract_id
|
||||||
|
self.approver_id = approver_id
|
||||||
|
self.status = status
|
||||||
|
self.comment = comment
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
106
backend/app/models/law.py
Normal file
106
backend/app/models/law.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""Law model for legal regulations."""
|
||||||
|
import enum
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, Date, DateTime, Enum as SQLEnum, Integer, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LawType(str, enum.Enum):
|
||||||
|
"""Law type enumeration."""
|
||||||
|
LAW = "law" # 法律
|
||||||
|
REGULATION = "regulation" # 法规
|
||||||
|
RULE = "rule" # 规章
|
||||||
|
JUDICIAL_INTERPRETATION = "judicial_interpretation" # 司法解释
|
||||||
|
|
||||||
|
|
||||||
|
class LawStatus(str, enum.Enum):
|
||||||
|
"""Law status enumeration."""
|
||||||
|
EFFECTIVE = "effective" # 有效
|
||||||
|
REVOKED = "revoked" # 废止
|
||||||
|
AMENDED = "amended" # 修订
|
||||||
|
|
||||||
|
|
||||||
|
class Law(Base):
|
||||||
|
"""Law model for legal regulations."""
|
||||||
|
|
||||||
|
__tablename__ = "laws"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), index=True)
|
||||||
|
law_type: Mapped[LawType] = mapped_column(SQLEnum(LawType), nullable=False)
|
||||||
|
promulgation_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
effective_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
status: Mapped[LawStatus] = mapped_column(
|
||||||
|
SQLEnum(LawStatus),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
issuing_authority: Mapped[str] = mapped_column(String(100))
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
document_number: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
articles: Mapped[List["LawArticle"]] = relationship(
|
||||||
|
back_populates="law", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
law_type: LawType,
|
||||||
|
promulgation_date: date,
|
||||||
|
effective_date: date,
|
||||||
|
issuing_authority: str,
|
||||||
|
content: str,
|
||||||
|
status: LawStatus = LawStatus.EFFECTIVE,
|
||||||
|
document_number: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.title = title
|
||||||
|
self.law_type = law_type
|
||||||
|
self.promulgation_date = promulgation_date
|
||||||
|
self.effective_date = effective_date
|
||||||
|
self.status = status
|
||||||
|
self.issuing_authority = issuing_authority
|
||||||
|
self.content = content
|
||||||
|
self.document_number = document_number
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Law(id={self.id}, title={self.title})>"
|
||||||
|
|
||||||
|
|
||||||
|
class LawArticle(Base):
|
||||||
|
"""Law article model for individual articles."""
|
||||||
|
|
||||||
|
__tablename__ = "law_articles"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
law_id: Mapped[int] = mapped_column(Integer, ForeignKey("laws.id"), index=True)
|
||||||
|
article_number: Mapped[str] = mapped_column(String(20))
|
||||||
|
content: Mapped[str] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
law: Mapped["Law"] = relationship(back_populates="articles")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
law_id: int,
|
||||||
|
article_number: str,
|
||||||
|
content: str,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.law_id = law_id
|
||||||
|
self.article_number = article_number
|
||||||
|
self.content = content
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<LawArticle(id={self.id}, article_number={self.article_number})>"
|
||||||
119
backend/app/models/signature.py
Normal file
119
backend/app/models/signature.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Signature model for electronic signatures."""
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text, DateTime, Enum as SQLEnum, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureStatus(str, enum.Enum):
|
||||||
|
"""Signature status enumeration."""
|
||||||
|
PENDING = "pending"
|
||||||
|
SIGNED = "signed"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureRequest(Base):
|
||||||
|
"""Signature request model."""
|
||||||
|
|
||||||
|
__tablename__ = "signature_requests"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
contract_id: Mapped[int] = mapped_column(Integer, ForeignKey("contracts.id"), index=True)
|
||||||
|
requester_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), index=True)
|
||||||
|
signer_name: Mapped[str] = mapped_column(String(100))
|
||||||
|
signer_email: Mapped[str] = mapped_column(String(100))
|
||||||
|
status: Mapped[SignatureStatus] = mapped_column(
|
||||||
|
SQLEnum(SignatureStatus),
|
||||||
|
default=SignatureStatus.PENDING
|
||||||
|
)
|
||||||
|
token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
requester_id: int,
|
||||||
|
signer_name: str,
|
||||||
|
signer_email: str,
|
||||||
|
token: str,
|
||||||
|
expires_at: datetime,
|
||||||
|
status: SignatureStatus = SignatureStatus.PENDING,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.contract_id = contract_id
|
||||||
|
self.requester_id = requester_id
|
||||||
|
self.signer_name = signer_name
|
||||||
|
self.signer_email = signer_email
|
||||||
|
self.token = token
|
||||||
|
self.expires_at = expires_at
|
||||||
|
self.status = status
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class Signature(Base):
|
||||||
|
"""Signature record model."""
|
||||||
|
|
||||||
|
__tablename__ = "signatures"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
request_id: Mapped[int] = mapped_column(Integer, ForeignKey("signature_requests.id"), index=True)
|
||||||
|
signer_name: Mapped[str] = mapped_column(String(100))
|
||||||
|
signature_data: Mapped[str] = mapped_column(Text) # Base64 image or coordinates
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
verification_hash: Mapped[str] = mapped_column(String(64)) # Document fingerprint
|
||||||
|
signed_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request_id: int,
|
||||||
|
signer_name: str,
|
||||||
|
signature_data: str,
|
||||||
|
verification_hash: str,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.request_id = request_id
|
||||||
|
self.signer_name = signer_name
|
||||||
|
self.signature_data = signature_data
|
||||||
|
self.verification_hash = verification_hash
|
||||||
|
self.ip_address = ip_address
|
||||||
|
self.user_agent = user_agent
|
||||||
|
self.signed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureAudit(Base):
|
||||||
|
"""Signature audit log model."""
|
||||||
|
|
||||||
|
__tablename__ = "signature_audits"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
signature_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("signatures.id"), nullable=True)
|
||||||
|
request_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("signature_requests.id"), nullable=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(50))
|
||||||
|
details: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
signature_id: Optional[int] = None,
|
||||||
|
request_id: Optional[int] = None,
|
||||||
|
details: Optional[dict] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.action = action
|
||||||
|
self.signature_id = signature_id
|
||||||
|
self.request_id = request_id
|
||||||
|
self.details = details
|
||||||
|
self.ip_address = ip_address
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
64
backend/app/models/user.py
Normal file
64
backend/app/models/user.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""User model for authentication and authorization."""
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
"""User role enumeration."""
|
||||||
|
ADMIN = "admin"
|
||||||
|
LAWYER = "lawyer"
|
||||||
|
REVIEWER = "reviewer"
|
||||||
|
CLIENT = "client"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model for authentication."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(100), unique=True, index=True)
|
||||||
|
hashed_password: Mapped[str] = mapped_column(String(255))
|
||||||
|
full_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
role: Mapped[UserRole] = mapped_column(
|
||||||
|
SQLEnum(UserRole),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
email: str,
|
||||||
|
hashed_password: str,
|
||||||
|
full_name: Optional[str] = None,
|
||||||
|
role: UserRole = UserRole.CLIENT,
|
||||||
|
is_active: bool = True,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.username = username
|
||||||
|
self.email = email
|
||||||
|
self.hashed_password = hashed_password
|
||||||
|
self.full_name = full_name
|
||||||
|
self.role = role
|
||||||
|
self.is_active = is_active
|
||||||
|
self.created_at = datetime.utcnow()
|
||||||
|
self.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User(id={self.id}, username={self.username}, role={self.role})>"
|
||||||
1
backend/app/schemas/__init__.py
Normal file
1
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Schemas package
|
||||||
87
backend/app/schemas/analysis.py
Normal file
87
backend/app/schemas/analysis.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""Schemas for Analysis module."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisStatusEnum(str, Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
|
class LegalAnalysisBase(BaseModel):
|
||||||
|
"""Base schema for LegalAnalysis."""
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
case_description: str
|
||||||
|
|
||||||
|
|
||||||
|
class LegalAnalysisCreate(LegalAnalysisBase):
|
||||||
|
"""Schema for creating a legal analysis."""
|
||||||
|
legal_basis: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LegalAnalysisUpdate(BaseModel):
|
||||||
|
"""Schema for updating a legal analysis."""
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
case_description: Optional[str] = None
|
||||||
|
legal_basis: Optional[dict] = None
|
||||||
|
analysis_content: Optional[str] = None
|
||||||
|
conclusion: Optional[str] = None
|
||||||
|
status: Optional[AnalysisStatusEnum] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LegalAnalysisResponse(LegalAnalysisBase):
|
||||||
|
"""Schema for legal analysis response."""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
legal_basis: Optional[dict] = None
|
||||||
|
analysis_content: Optional[str] = None
|
||||||
|
conclusion: Optional[str] = None
|
||||||
|
status: AnalysisStatusEnum
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CaseBase(BaseModel):
|
||||||
|
"""Base schema for Case."""
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
case_number: Optional[str] = Field(None, max_length=50)
|
||||||
|
court: Optional[str] = Field(None, max_length=100)
|
||||||
|
case_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
judgment_date: Optional[date] = None
|
||||||
|
facts: Optional[str] = None
|
||||||
|
judgment: Optional[str] = None
|
||||||
|
reasoning: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaseCreate(CaseBase):
|
||||||
|
"""Schema for creating a case."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CaseResponse(CaseBase):
|
||||||
|
"""Schema for case response."""
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateAnalysisRequest(BaseModel):
|
||||||
|
"""Schema for generating analysis with AI."""
|
||||||
|
case_description: str
|
||||||
|
relevant_laws: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateAnalysisResponse(BaseModel):
|
||||||
|
"""Schema for generated analysis response."""
|
||||||
|
analysis_content: str
|
||||||
|
legal_basis: List[str] = []
|
||||||
|
conclusion: str
|
||||||
112
backend/app/schemas/contract.py
Normal file
112
backend/app/schemas/contract.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Schemas for Contract module."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ContractStatusEnum(str, Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
APPROVED = "approved"
|
||||||
|
PENDING_SIGNATURE = "pending_signature"
|
||||||
|
SIGNED = "signed"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class ContractBase(BaseModel):
|
||||||
|
"""Base schema for Contract."""
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
party_a: str = Field(..., max_length=100)
|
||||||
|
party_b: str = Field(..., max_length=100)
|
||||||
|
content: str
|
||||||
|
contract_number: Optional[str] = Field(None, max_length=50)
|
||||||
|
effective_date: Optional[date] = None
|
||||||
|
expiry_date: Optional[date] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContractCreate(ContractBase):
|
||||||
|
"""Schema for creating a contract."""
|
||||||
|
template_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContractUpdate(BaseModel):
|
||||||
|
"""Schema for updating a contract."""
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
party_a: Optional[str] = Field(None, max_length=100)
|
||||||
|
party_b: Optional[str] = Field(None, max_length=100)
|
||||||
|
content: Optional[str] = None
|
||||||
|
contract_number: Optional[str] = Field(None, max_length=50)
|
||||||
|
effective_date: Optional[date] = None
|
||||||
|
expiry_date: Optional[date] = None
|
||||||
|
status: Optional[ContractStatusEnum] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContractResponse(ContractBase):
|
||||||
|
"""Schema for contract response."""
|
||||||
|
id: int
|
||||||
|
template_id: Optional[int] = None
|
||||||
|
status: ContractStatusEnum
|
||||||
|
file_path: Optional[str] = None
|
||||||
|
created_by: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateBase(BaseModel):
|
||||||
|
"""Base schema for ContractTemplate."""
|
||||||
|
name: str = Field(..., max_length=100)
|
||||||
|
content: str
|
||||||
|
contract_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateCreate(ContractTemplateBase):
|
||||||
|
"""Schema for creating a contract template."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateResponse(ContractTemplateBase):
|
||||||
|
"""Schema for contract template response."""
|
||||||
|
id: int
|
||||||
|
created_by: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalRequest(BaseModel):
|
||||||
|
"""Schema for approval request."""
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalResponse(BaseModel):
|
||||||
|
"""Schema for approval response."""
|
||||||
|
id: int
|
||||||
|
contract_id: int
|
||||||
|
approver_id: int
|
||||||
|
status: ContractStatusEnum
|
||||||
|
comment: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
approved_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ContractReviewRequest(BaseModel):
|
||||||
|
"""Schema for AI contract review request."""
|
||||||
|
contract_content: str
|
||||||
|
contract_type: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContractReviewResponse(BaseModel):
|
||||||
|
"""Schema for AI contract review response."""
|
||||||
|
review_result: str
|
||||||
|
risks: list = []
|
||||||
|
suggestions: list = []
|
||||||
107
backend/app/schemas/law.py
Normal file
107
backend/app/schemas/law.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Schemas for Law module."""
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LawTypeEnum(str, Enum):
|
||||||
|
LAW = "law"
|
||||||
|
REGULATION = "regulation"
|
||||||
|
RULE = "rule"
|
||||||
|
JUDICIAL_INTERPRETATION = "judicial_interpretation"
|
||||||
|
|
||||||
|
|
||||||
|
class LawStatusEnum(str, Enum):
|
||||||
|
EFFECTIVE = "effective"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
AMENDED = "amended"
|
||||||
|
|
||||||
|
|
||||||
|
class LawBase(BaseModel):
|
||||||
|
"""Base schema for Law."""
|
||||||
|
title: str = Field(..., max_length=200)
|
||||||
|
law_type: LawTypeEnum
|
||||||
|
promulgation_date: date
|
||||||
|
effective_date: date
|
||||||
|
issuing_authority: str = Field(..., max_length=100)
|
||||||
|
content: str
|
||||||
|
status: LawStatusEnum = LawStatusEnum.EFFECTIVE
|
||||||
|
document_number: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class LawCreate(LawBase):
|
||||||
|
"""Schema for creating a law."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LawUpdate(BaseModel):
|
||||||
|
"""Schema for updating a law."""
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
law_type: Optional[LawTypeEnum] = None
|
||||||
|
promulgation_date: Optional[date] = None
|
||||||
|
effective_date: Optional[date] = None
|
||||||
|
status: Optional[LawStatusEnum] = None
|
||||||
|
issuing_authority: Optional[str] = Field(None, max_length=100)
|
||||||
|
content: Optional[str] = None
|
||||||
|
document_number: Optional[str] = Field(None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class LawArticleBase(BaseModel):
|
||||||
|
"""Base schema for LawArticle."""
|
||||||
|
article_number: str = Field(..., max_length=20)
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class LawArticleCreate(LawArticleBase):
|
||||||
|
"""Schema for creating a law article."""
|
||||||
|
law_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class LawArticleResponse(LawArticleBase):
|
||||||
|
"""Schema for law article response."""
|
||||||
|
id: int
|
||||||
|
law_id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LawResponse(LawBase):
|
||||||
|
"""Schema for law response."""
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
articles: List[LawArticleResponse] = []
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LawListResponse(BaseModel):
|
||||||
|
"""Schema for law list response."""
|
||||||
|
items: List[LawResponse]
|
||||||
|
total: int
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class LawSearchRequest(BaseModel):
|
||||||
|
"""Schema for law search request."""
|
||||||
|
keyword: str = Field(..., min_length=1)
|
||||||
|
limit: int = Field(10, ge=1, le=50)
|
||||||
|
|
||||||
|
|
||||||
|
class LegalQARequest(BaseModel):
|
||||||
|
"""Schema for legal QA request."""
|
||||||
|
question: str = Field(..., min_length=1)
|
||||||
|
context: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LegalQAResponse(BaseModel):
|
||||||
|
"""Schema for legal QA response."""
|
||||||
|
question: str
|
||||||
|
answer: str
|
||||||
|
references: List[str] = []
|
||||||
61
backend/app/schemas/signature.py
Normal file
61
backend/app/schemas/signature.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""Schemas for Signature module."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureStatusEnum(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
SIGNED = "signed"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureRequestCreate(BaseModel):
|
||||||
|
"""Schema for creating a signature request."""
|
||||||
|
contract_id: int
|
||||||
|
signer_name: str = Field(..., max_length=100)
|
||||||
|
signer_email: str = Field(..., max_length=100)
|
||||||
|
expire_hours: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureRequestResponse(BaseModel):
|
||||||
|
"""Schema for signature request response."""
|
||||||
|
id: int
|
||||||
|
contract_id: int
|
||||||
|
requester_id: int
|
||||||
|
signer_name: str
|
||||||
|
signer_email: str
|
||||||
|
status: SignatureStatusEnum
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureSignRequest(BaseModel):
|
||||||
|
"""Schema for signing a document."""
|
||||||
|
signature_data: str # Base64 image or coordinates
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureResponse(BaseModel):
|
||||||
|
"""Schema for signature response."""
|
||||||
|
id: int
|
||||||
|
request_id: int
|
||||||
|
signer_name: str
|
||||||
|
signed_at: datetime
|
||||||
|
verification_hash: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureVerifyResponse(BaseModel):
|
||||||
|
"""Schema for signature verification response."""
|
||||||
|
valid: bool
|
||||||
|
signed_at: Optional[datetime] = None
|
||||||
|
signer_name: Optional[str] = None
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Services package
|
||||||
134
backend/app/services/analysis_service.py
Normal file
134
backend/app/services/analysis_service.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Analysis service for legal research."""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.analysis import LegalAnalysis, Case, AnalysisStatus
|
||||||
|
|
||||||
|
|
||||||
|
class AnalysisService:
|
||||||
|
"""Service for legal analysis operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_analysis(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
case_description: str,
|
||||||
|
legal_basis: Optional[dict] = None,
|
||||||
|
) -> LegalAnalysis:
|
||||||
|
"""Create a new legal analysis."""
|
||||||
|
analysis = LegalAnalysis(
|
||||||
|
user_id=user_id,
|
||||||
|
title=title,
|
||||||
|
case_description=case_description,
|
||||||
|
legal_basis=legal_basis,
|
||||||
|
)
|
||||||
|
self.db.add(analysis)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(analysis)
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
async def get_analysis_by_id(self, analysis_id: int) -> Optional[LegalAnalysis]:
|
||||||
|
"""Get an analysis by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(LegalAnalysis).where(LegalAnalysis.id == analysis_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_analyses_by_user(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[LegalAnalysis]:
|
||||||
|
"""Get analyses by user ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(LegalAnalysis)
|
||||||
|
.where(LegalAnalysis.user_id == user_id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.order_by(LegalAnalysis.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update_analysis(
|
||||||
|
self,
|
||||||
|
analysis_id: int,
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[LegalAnalysis]:
|
||||||
|
"""Update an analysis."""
|
||||||
|
analysis = await self.get_analysis_by_id(analysis_id)
|
||||||
|
if not analysis:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(analysis, key) and value is not None:
|
||||||
|
setattr(analysis, key, value)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(analysis)
|
||||||
|
return analysis
|
||||||
|
|
||||||
|
async def delete_analysis(self, analysis_id: int) -> bool:
|
||||||
|
"""Delete an analysis."""
|
||||||
|
analysis = await self.get_analysis_by_id(analysis_id)
|
||||||
|
if not analysis:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.db.delete(analysis)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class CaseService:
|
||||||
|
"""Service for case operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_case(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
case_number: Optional[str] = None,
|
||||||
|
court: Optional[str] = None,
|
||||||
|
case_type: Optional[str] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> Case:
|
||||||
|
"""Create a new case."""
|
||||||
|
case = Case(
|
||||||
|
title=title,
|
||||||
|
case_number=case_number,
|
||||||
|
court=court,
|
||||||
|
case_type=case_type,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self.db.add(case)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(case)
|
||||||
|
return case
|
||||||
|
|
||||||
|
async def get_case_by_id(self, case_id: int) -> Optional[Case]:
|
||||||
|
"""Get a case by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Case).where(Case.id == case_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_cases_list(
|
||||||
|
self,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
case_type: Optional[str] = None,
|
||||||
|
) -> List[Case]:
|
||||||
|
"""Get list of cases."""
|
||||||
|
query = select(Case)
|
||||||
|
|
||||||
|
if case_type:
|
||||||
|
query = query.where(Case.case_type == case_type)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit).order_by(Case.created_at.desc())
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
171
backend/app/services/contract_service.py
Normal file
171
backend/app/services/contract_service.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
"""Contract service for contract management."""
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.contract import Contract, ContractTemplate, ContractApproval, ContractStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ContractService:
|
||||||
|
"""Service for contract operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_contract(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
party_a: str,
|
||||||
|
party_b: str,
|
||||||
|
content: str,
|
||||||
|
created_by: int,
|
||||||
|
**kwargs
|
||||||
|
) -> Contract:
|
||||||
|
"""Create a new contract."""
|
||||||
|
contract = Contract(
|
||||||
|
title=title,
|
||||||
|
party_a=party_a,
|
||||||
|
party_b=party_b,
|
||||||
|
content=content,
|
||||||
|
created_by=created_by,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
self.db.add(contract)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
async def get_contract_by_id(self, contract_id: int) -> Optional[Contract]:
|
||||||
|
"""Get a contract by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Contract).where(Contract.id == contract_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_contracts_list(
|
||||||
|
self,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
status: Optional[ContractStatus] = None,
|
||||||
|
created_by: Optional[int] = None,
|
||||||
|
) -> List[Contract]:
|
||||||
|
"""Get list of contracts."""
|
||||||
|
query = select(Contract)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.where(Contract.status == status)
|
||||||
|
if created_by:
|
||||||
|
query = query.where(Contract.created_by == created_by)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit).order_by(Contract.created_at.desc())
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update_contract(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[Contract]:
|
||||||
|
"""Update a contract."""
|
||||||
|
contract = await self.get_contract_by_id(contract_id)
|
||||||
|
if not contract:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(contract, key) and value is not None:
|
||||||
|
setattr(contract, key, value)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
async def submit_for_approval(self, contract_id: int) -> Optional[Contract]:
|
||||||
|
"""Submit a contract for approval."""
|
||||||
|
return await self.update_contract(
|
||||||
|
contract_id,
|
||||||
|
status=ContractStatus.PENDING_APPROVAL
|
||||||
|
)
|
||||||
|
|
||||||
|
async def approve_contract(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
approver_id: int,
|
||||||
|
comment: Optional[str] = None,
|
||||||
|
) -> ContractApproval:
|
||||||
|
"""Approve a contract."""
|
||||||
|
approval = ContractApproval(
|
||||||
|
contract_id=contract_id,
|
||||||
|
approver_id=approver_id,
|
||||||
|
status=ContractStatus.APPROVED,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
self.db.add(approval)
|
||||||
|
|
||||||
|
# Update contract status
|
||||||
|
await self.update_contract(contract_id, status=ContractStatus.APPROVED)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(approval)
|
||||||
|
return approval
|
||||||
|
|
||||||
|
async def reject_contract(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
approver_id: int,
|
||||||
|
comment: Optional[str] = None,
|
||||||
|
) -> ContractApproval:
|
||||||
|
"""Reject a contract."""
|
||||||
|
approval = ContractApproval(
|
||||||
|
contract_id=contract_id,
|
||||||
|
approver_id=approver_id,
|
||||||
|
status=ContractStatus.REJECTED,
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
self.db.add(approval)
|
||||||
|
|
||||||
|
# Update contract status
|
||||||
|
await self.update_contract(contract_id, status=ContractStatus.REJECTED)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(approval)
|
||||||
|
return approval
|
||||||
|
|
||||||
|
|
||||||
|
class ContractTemplateService:
|
||||||
|
"""Service for contract template operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_template(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
content: str,
|
||||||
|
created_by: int,
|
||||||
|
contract_type: Optional[str] = None,
|
||||||
|
) -> ContractTemplate:
|
||||||
|
"""Create a contract template."""
|
||||||
|
template = ContractTemplate(
|
||||||
|
name=name,
|
||||||
|
content=content,
|
||||||
|
created_by=created_by,
|
||||||
|
contract_type=contract_type,
|
||||||
|
)
|
||||||
|
self.db.add(template)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(template)
|
||||||
|
return template
|
||||||
|
|
||||||
|
async def get_templates_list(
|
||||||
|
self,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> List[ContractTemplate]:
|
||||||
|
"""Get list of templates."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(ContractTemplate)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.order_by(ContractTemplate.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
137
backend/app/services/law_service.py
Normal file
137
backend/app/services/law_service.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"""Law service for CRUD operations."""
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.law import Law, LawArticle, LawType, LawStatus
|
||||||
|
|
||||||
|
|
||||||
|
class LawService:
|
||||||
|
"""Service for law CRUD operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def create_law(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
law_type: LawType,
|
||||||
|
promulgation_date: date,
|
||||||
|
effective_date: date,
|
||||||
|
issuing_authority: str,
|
||||||
|
content: str,
|
||||||
|
status: LawStatus = LawStatus.EFFECTIVE,
|
||||||
|
document_number: Optional[str] = None,
|
||||||
|
) -> Law:
|
||||||
|
"""Create a new law."""
|
||||||
|
law = Law(
|
||||||
|
title=title,
|
||||||
|
law_type=law_type,
|
||||||
|
promulgation_date=promulgation_date,
|
||||||
|
effective_date=effective_date,
|
||||||
|
status=status,
|
||||||
|
issuing_authority=issuing_authority,
|
||||||
|
content=content,
|
||||||
|
document_number=document_number,
|
||||||
|
)
|
||||||
|
self.db.add(law)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(law)
|
||||||
|
return law
|
||||||
|
|
||||||
|
async def get_law_by_id(self, law_id: int) -> Optional[Law]:
|
||||||
|
"""Get a law by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Law).where(Law.id == law_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_laws_list(
|
||||||
|
self,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
law_type: Optional[LawType] = None,
|
||||||
|
status: Optional[LawStatus] = None,
|
||||||
|
) -> List[Law]:
|
||||||
|
"""Get list of laws with optional filters."""
|
||||||
|
query = select(Law)
|
||||||
|
|
||||||
|
if law_type:
|
||||||
|
query = query.where(Law.law_type == law_type)
|
||||||
|
if status:
|
||||||
|
query = query.where(Law.status == status)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit).order_by(Law.created_at.desc())
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update_law(
|
||||||
|
self,
|
||||||
|
law_id: int,
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[Law]:
|
||||||
|
"""Update a law."""
|
||||||
|
law = await self.get_law_by_id(law_id)
|
||||||
|
if not law:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(law, key) and value is not None:
|
||||||
|
setattr(law, key, value)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(law)
|
||||||
|
return law
|
||||||
|
|
||||||
|
async def delete_law(self, law_id: int) -> bool:
|
||||||
|
"""Delete a law."""
|
||||||
|
law = await self.get_law_by_id(law_id)
|
||||||
|
if not law:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await self.db.delete(law)
|
||||||
|
await self.db.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def search_laws_by_keyword(
|
||||||
|
self,
|
||||||
|
keyword: str,
|
||||||
|
limit: int = 10
|
||||||
|
) -> List[Law]:
|
||||||
|
"""Search laws by keyword in title or content."""
|
||||||
|
query = select(Law).where(
|
||||||
|
or_(
|
||||||
|
Law.title.contains(keyword),
|
||||||
|
Law.content.contains(keyword),
|
||||||
|
)
|
||||||
|
).limit(limit)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create_article(
|
||||||
|
self,
|
||||||
|
law_id: int,
|
||||||
|
article_number: str,
|
||||||
|
content: str,
|
||||||
|
) -> LawArticle:
|
||||||
|
"""Create a law article."""
|
||||||
|
article = LawArticle(
|
||||||
|
law_id=law_id,
|
||||||
|
article_number=article_number,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
self.db.add(article)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(article)
|
||||||
|
return article
|
||||||
|
|
||||||
|
async def get_articles_by_law_id(self, law_id: int) -> List[LawArticle]:
|
||||||
|
"""Get articles for a law."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(LawArticle)
|
||||||
|
.where(LawArticle.law_id == law_id)
|
||||||
|
.order_by(LawArticle.id)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
123
backend/app/services/llm_service.py
Normal file
123
backend/app/services/llm_service.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""LLM service for AI-powered features."""
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class LLMService:
|
||||||
|
"""Service for LLM API interactions."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_base = settings.LLM_API_BASE
|
||||||
|
self.api_key = settings.LLM_API_KEY
|
||||||
|
self.model = settings.LLM_MODEL
|
||||||
|
|
||||||
|
async def chat_completion(
|
||||||
|
self,
|
||||||
|
messages: List[Dict[str, str]],
|
||||||
|
temperature: float = 0.7,
|
||||||
|
max_tokens: int = 2000,
|
||||||
|
) -> str:
|
||||||
|
"""Get chat completion from LLM."""
|
||||||
|
if not self.api_key:
|
||||||
|
# Return mock response for testing
|
||||||
|
return "这是一个模拟的法律回复。"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_base}/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
|
async def legal_qa(
|
||||||
|
self,
|
||||||
|
question: str,
|
||||||
|
context: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Answer a legal question."""
|
||||||
|
system_prompt = """你是一个专业的法律助手。请根据以下原则回答问题:
|
||||||
|
1. 准确引用相关法律条文
|
||||||
|
2. 解释法律条文的含义和适用条件
|
||||||
|
3. 提供实用的法律建议
|
||||||
|
4. 如不确定,请明确说明"""
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
]
|
||||||
|
|
||||||
|
if context:
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": f"参考以下法律内容:\n{context}\n\n问题:{question}"
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
return await self.chat_completion(messages)
|
||||||
|
|
||||||
|
async def analyze_legal_issue(
|
||||||
|
self,
|
||||||
|
issue_description: str,
|
||||||
|
relevant_laws: Optional[List[str]] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Analyze a legal issue and provide insights."""
|
||||||
|
system_prompt = """你是一个专业的法律分析师。请根据以下结构分析法律问题:
|
||||||
|
1. 问题定性
|
||||||
|
2. 相关法律依据
|
||||||
|
3. 法律分析
|
||||||
|
4. 风险提示
|
||||||
|
5. 建议"""
|
||||||
|
|
||||||
|
user_content = f"请分析以下法律问题:\n{issue_description}"
|
||||||
|
|
||||||
|
if relevant_laws:
|
||||||
|
user_content += f"\n\n相关法律:\n" + "\n".join(relevant_laws)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
]
|
||||||
|
|
||||||
|
return await self.chat_completion(messages, temperature=0.5)
|
||||||
|
|
||||||
|
async def review_contract(
|
||||||
|
self,
|
||||||
|
contract_content: str,
|
||||||
|
contract_type: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
"""Review a contract and identify potential issues."""
|
||||||
|
system_prompt = """你是一个专业的合同审查专家。请审查合同并:
|
||||||
|
1. 识别潜在风险条款
|
||||||
|
2. 指出不明确的条款
|
||||||
|
3. 提出修改建议
|
||||||
|
4. 检查法律合规性"""
|
||||||
|
|
||||||
|
user_content = f"请审查以下合同内容:\n{contract_content}"
|
||||||
|
|
||||||
|
if contract_type:
|
||||||
|
user_content = f"合同类型:{contract_type}\n\n{user_content}"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
]
|
||||||
|
|
||||||
|
return await self.chat_completion(messages, max_tokens=3000)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
llm_service = LLMService()
|
||||||
174
backend/app/services/signature_service.py
Normal file
174
backend/app/services/signature_service.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"""Signature service for electronic signatures."""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.signature import (
|
||||||
|
SignatureRequest,
|
||||||
|
Signature,
|
||||||
|
SignatureAudit,
|
||||||
|
SignatureStatus,
|
||||||
|
)
|
||||||
|
from app.models.contract import Contract
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureService:
|
||||||
|
"""Service for electronic signature operations."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def generate_token(self) -> str:
|
||||||
|
"""Generate a secure random token."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
def generate_verification_hash(self, content: str) -> str:
|
||||||
|
"""Generate a verification hash for document content."""
|
||||||
|
return hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
|
||||||
|
async def create_signature_request(
|
||||||
|
self,
|
||||||
|
contract_id: int,
|
||||||
|
requester_id: int,
|
||||||
|
signer_name: str,
|
||||||
|
signer_email: str,
|
||||||
|
expire_hours: int = None,
|
||||||
|
) -> SignatureRequest:
|
||||||
|
"""Create a signature request."""
|
||||||
|
expire_hours = expire_hours or settings.SIGNATURE_TOKEN_EXPIRE_HOURS
|
||||||
|
|
||||||
|
request = SignatureRequest(
|
||||||
|
contract_id=contract_id,
|
||||||
|
requester_id=requester_id,
|
||||||
|
signer_name=signer_name,
|
||||||
|
signer_email=signer_email,
|
||||||
|
token=self.generate_token(),
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=expire_hours),
|
||||||
|
)
|
||||||
|
self.db.add(request)
|
||||||
|
|
||||||
|
# Create audit log
|
||||||
|
await self.create_audit_log(
|
||||||
|
action="request_created",
|
||||||
|
request_id=request.id,
|
||||||
|
details={"signer_email": signer_email}
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(request)
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def get_signature_request_by_token(
|
||||||
|
self,
|
||||||
|
token: str
|
||||||
|
) -> Optional[SignatureRequest]:
|
||||||
|
"""Get a signature request by token."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SignatureRequest).where(SignatureRequest.token == token)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_signature_request_by_id(
|
||||||
|
self,
|
||||||
|
request_id: int
|
||||||
|
) -> Optional[SignatureRequest]:
|
||||||
|
"""Get a signature request by ID."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SignatureRequest).where(SignatureRequest.id == request_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def is_request_valid(self, request: SignatureRequest) -> bool:
|
||||||
|
"""Check if a signature request is valid."""
|
||||||
|
if request.status != SignatureStatus.PENDING:
|
||||||
|
return False
|
||||||
|
if request.expires_at < datetime.utcnow():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def sign_document(
|
||||||
|
self,
|
||||||
|
request: SignatureRequest,
|
||||||
|
signature_data: str,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
) -> Signature:
|
||||||
|
"""Sign a document."""
|
||||||
|
# Get contract content for hash
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Contract).where(Contract.id == request.contract_id)
|
||||||
|
)
|
||||||
|
contract = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not contract:
|
||||||
|
raise ValueError("Contract not found")
|
||||||
|
|
||||||
|
verification_hash = self.generate_verification_hash(contract.content)
|
||||||
|
|
||||||
|
signature = Signature(
|
||||||
|
request_id=request.id,
|
||||||
|
signer_name=request.signer_name,
|
||||||
|
signature_data=signature_data,
|
||||||
|
verification_hash=verification_hash,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
)
|
||||||
|
self.db.add(signature)
|
||||||
|
|
||||||
|
# Update request status
|
||||||
|
request.status = SignatureStatus.SIGNED
|
||||||
|
|
||||||
|
# Create audit log
|
||||||
|
await self.create_audit_log(
|
||||||
|
action="document_signed",
|
||||||
|
signature_id=signature.id,
|
||||||
|
request_id=request.id,
|
||||||
|
ip_address=ip_address
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(signature)
|
||||||
|
return signature
|
||||||
|
|
||||||
|
async def verify_signature(
|
||||||
|
self,
|
||||||
|
signature_id: int,
|
||||||
|
content: str
|
||||||
|
) -> bool:
|
||||||
|
"""Verify a signature against document content."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Signature).where(Signature.id == signature_id)
|
||||||
|
)
|
||||||
|
signature = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not signature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_hash = self.generate_verification_hash(content)
|
||||||
|
return current_hash == signature.verification_hash
|
||||||
|
|
||||||
|
async def create_audit_log(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
signature_id: Optional[int] = None,
|
||||||
|
request_id: Optional[int] = None,
|
||||||
|
details: Optional[dict] = None,
|
||||||
|
ip_address: Optional[str] = None,
|
||||||
|
) -> SignatureAudit:
|
||||||
|
"""Create an audit log entry."""
|
||||||
|
audit = SignatureAudit(
|
||||||
|
action=action,
|
||||||
|
signature_id=signature_id,
|
||||||
|
request_id=request_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
self.db.add(audit)
|
||||||
|
await self.db.flush()
|
||||||
|
await self.db.refresh(audit)
|
||||||
|
return audit
|
||||||
107
backend/app/services/vector_service.py
Normal file
107
backend/app/services/vector_service.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Vector service for embedding and similarity search."""
|
||||||
|
import numpy as np
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class VectorService:
|
||||||
|
"""Service for vector embeddings and similarity search."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_base = settings.EMBEDDING_API_BASE
|
||||||
|
self.api_key = settings.EMBEDDING_API_KEY or settings.LLM_API_KEY
|
||||||
|
self.model = settings.EMBEDDING_MODEL
|
||||||
|
self.dimension = settings.EMBEDDING_DIMENSION
|
||||||
|
|
||||||
|
async def get_embedding(self, text: str) -> List[float]:
|
||||||
|
"""Get embedding for a text using external API."""
|
||||||
|
if not self.api_key:
|
||||||
|
# Return mock embedding for testing
|
||||||
|
return [0.0] * self.dimension
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_base}/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"input": text,
|
||||||
|
},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data["data"][0]["embedding"]
|
||||||
|
|
||||||
|
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
|
||||||
|
"""Get embeddings for multiple texts."""
|
||||||
|
if not self.api_key:
|
||||||
|
return [[0.0] * self.dimension for _ in texts]
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.api_base}/embeddings",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"input": texts,
|
||||||
|
},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return [item["embedding"] for item in data["data"]]
|
||||||
|
|
||||||
|
def cosine_similarity(
|
||||||
|
self,
|
||||||
|
vec1: List[float],
|
||||||
|
vec2: List[float]
|
||||||
|
) -> float:
|
||||||
|
"""Calculate cosine similarity between two vectors."""
|
||||||
|
arr1 = np.array(vec1)
|
||||||
|
arr2 = np.array(vec2)
|
||||||
|
|
||||||
|
dot_product = np.dot(arr1, arr2)
|
||||||
|
norm1 = np.linalg.norm(arr1)
|
||||||
|
norm2 = np.linalg.norm(arr2)
|
||||||
|
|
||||||
|
if norm1 == 0 or norm2 == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return float(dot_product / (norm1 * norm2))
|
||||||
|
|
||||||
|
def search_similar(
|
||||||
|
self,
|
||||||
|
query_embedding: List[float],
|
||||||
|
stored_vectors: List[Dict[str, Any]],
|
||||||
|
top_k: int = 5
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Search for similar vectors."""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for item in stored_vectors:
|
||||||
|
similarity = self.cosine_similarity(
|
||||||
|
query_embedding,
|
||||||
|
item["embedding"]
|
||||||
|
)
|
||||||
|
results.append({
|
||||||
|
"id": item["id"],
|
||||||
|
"similarity": similarity,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by similarity descending
|
||||||
|
results.sort(key=lambda x: x["similarity"], reverse=True)
|
||||||
|
|
||||||
|
return results[:top_k]
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
vector_service = VectorService()
|
||||||
16
backend/pytest.ini
Normal file
16
backend/pytest.ini
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[tool:pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
|
||||||
|
[coverage:run]
|
||||||
|
source = app
|
||||||
|
omit = app/__init__.py
|
||||||
|
|
||||||
|
[coverage:report]
|
||||||
|
exclude_lines =
|
||||||
|
pragma: no cover
|
||||||
|
def __repr__
|
||||||
|
raise NotImplementedError
|
||||||
31
backend/requirements.txt
Normal file
31
backend/requirements.txt
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# FastAPI and dependencies
|
||||||
|
fastapi==0.110.0
|
||||||
|
uvicorn[standard]==0.27.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
|
||||||
|
# Database
|
||||||
|
sqlalchemy==2.0.25
|
||||||
|
aiosqlite==0.19.0
|
||||||
|
|
||||||
|
# Vector search
|
||||||
|
sqlite-vss==0.1.2
|
||||||
|
|
||||||
|
# Pydantic
|
||||||
|
pydantic==2.6.0
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
bcrypt==4.0.1
|
||||||
|
|
||||||
|
# HTTP client for external APIs
|
||||||
|
httpx==0.26.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==7.4.4
|
||||||
|
pytest-asyncio==0.23.4
|
||||||
|
pytest-cov==4.1.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
numpy==1.26.4
|
||||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Tests package
|
||||||
36
backend/tests/conftest.py
Normal file
36
backend/tests/conftest.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Pytest configuration and fixtures."""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from typing import AsyncGenerator
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
create_async_engine,
|
||||||
|
async_sessionmaker,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.law import Law, LawArticle
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(tmp_path) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Create a test database session."""
|
||||||
|
db_file = tmp_path / "test.db"
|
||||||
|
engine = create_async_engine(
|
||||||
|
f"sqlite+aiosqlite:///{db_file}",
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
1
backend/tests/unit/__init__.py
Normal file
1
backend/tests/unit/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Unit tests package
|
||||||
60
backend/tests/unit/test_config.py
Normal file
60
backend/tests/unit/test_config.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""Unit tests for core configuration."""
|
||||||
|
import pytest
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettings:
|
||||||
|
"""Test cases for Settings configuration."""
|
||||||
|
|
||||||
|
def test_default_settings(self):
|
||||||
|
"""Test default configuration values."""
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.APP_NAME == "AI Legal Assistant"
|
||||||
|
assert settings.APP_VERSION == "1.0.0"
|
||||||
|
assert settings.DEBUG is False
|
||||||
|
assert settings.DATABASE_URL == "sqlite+aiosqlite:///./data/legal_assistant.db"
|
||||||
|
|
||||||
|
def test_custom_settings_from_env(self, monkeypatch):
|
||||||
|
"""Test configuration from environment variables."""
|
||||||
|
monkeypatch.setenv("DEBUG", "true")
|
||||||
|
monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite:///./test.db")
|
||||||
|
monkeypatch.setenv("LLM_API_KEY", "test-key")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.DEBUG is True
|
||||||
|
assert settings.DATABASE_URL == "sqlite+aiosqlite:///./test.db"
|
||||||
|
assert settings.LLM_API_KEY == "test-key"
|
||||||
|
|
||||||
|
def test_llm_settings(self, monkeypatch):
|
||||||
|
"""Test LLM configuration."""
|
||||||
|
monkeypatch.setenv("LLM_API_BASE", "https://api.example.com/v1")
|
||||||
|
monkeypatch.setenv("LLM_MODEL", "gpt-4")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.LLM_API_BASE == "https://api.example.com/v1"
|
||||||
|
assert settings.LLM_MODEL == "gpt-4"
|
||||||
|
|
||||||
|
def test_embedding_settings(self, monkeypatch):
|
||||||
|
"""Test embedding configuration."""
|
||||||
|
monkeypatch.setenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||||
|
monkeypatch.setenv("EMBEDDING_DIMENSION", "1536")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.EMBEDDING_MODEL == "text-embedding-3-small"
|
||||||
|
assert settings.EMBEDDING_DIMENSION == 1536
|
||||||
|
|
||||||
|
def test_jwt_settings(self, monkeypatch):
|
||||||
|
"""Test JWT configuration."""
|
||||||
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-key")
|
||||||
|
monkeypatch.setenv("JWT_ALGORITHM", "HS256")
|
||||||
|
monkeypatch.setenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "60")
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.JWT_SECRET_KEY == "test-secret-key"
|
||||||
|
assert settings.JWT_ALGORITHM == "HS256"
|
||||||
|
assert settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES == 60
|
||||||
100
backend/tests/unit/test_contract_service.py
Normal file
100
backend/tests/unit/test_contract_service.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""Unit tests for Contract service."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.contract import ContractStatus
|
||||||
|
from app.services.contract_service import ContractService, ContractTemplateService
|
||||||
|
|
||||||
|
|
||||||
|
class TestContractService:
|
||||||
|
"""Test cases for Contract service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_contract(self, db_session: AsyncSession):
|
||||||
|
"""Test creating a contract."""
|
||||||
|
service = ContractService(db_session)
|
||||||
|
|
||||||
|
contract = await service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert contract.id is not None
|
||||||
|
assert contract.title == "测试合同"
|
||||||
|
assert contract.status == ContractStatus.DRAFT
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_contract_by_id(self, db_session: AsyncSession):
|
||||||
|
"""Test getting a contract by ID."""
|
||||||
|
service = ContractService(db_session)
|
||||||
|
|
||||||
|
created = await service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
contract = await service.get_contract_by_id(created.id)
|
||||||
|
|
||||||
|
assert contract is not None
|
||||||
|
assert contract.title == "测试合同"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_submit_for_approval(self, db_session: AsyncSession):
|
||||||
|
"""Test submitting a contract for approval."""
|
||||||
|
service = ContractService(db_session)
|
||||||
|
|
||||||
|
contract = await service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await service.submit_for_approval(contract.id)
|
||||||
|
|
||||||
|
assert updated.status == ContractStatus.PENDING_APPROVAL
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_contract(self, db_session: AsyncSession):
|
||||||
|
"""Test approving a contract."""
|
||||||
|
service = ContractService(db_session)
|
||||||
|
|
||||||
|
contract = await service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
approval = await service.approve_contract(contract.id, 1, "同意")
|
||||||
|
|
||||||
|
assert approval.status == ContractStatus.APPROVED
|
||||||
|
|
||||||
|
|
||||||
|
class TestContractTemplateService:
|
||||||
|
"""Test cases for ContractTemplate service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_template(self, db_session: AsyncSession):
|
||||||
|
"""Test creating a contract template."""
|
||||||
|
service = ContractTemplateService(db_session)
|
||||||
|
|
||||||
|
template = await service.create_template(
|
||||||
|
name="测试模板",
|
||||||
|
content="模板内容",
|
||||||
|
created_by=1,
|
||||||
|
contract_type="销售合同",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.id is not None
|
||||||
|
assert template.name == "测试模板"
|
||||||
51
backend/tests/unit/test_database.py
Normal file
51
backend/tests/unit/test_database.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Unit tests for database configuration."""
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.database import Base, get_db, init_db
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabase:
|
||||||
|
"""Test cases for database configuration."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_tables(self, tmp_path):
|
||||||
|
"""Test creating database tables."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
engine = create_async_engine(
|
||||||
|
f"sqlite+aiosqlite:///{db_path}",
|
||||||
|
echo=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
# Verify tables exist
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
result = await conn.execute(text(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
))
|
||||||
|
tables = [row[0] for row in result.fetchall()]
|
||||||
|
# At minimum, alembic_version table should exist after migration
|
||||||
|
# For now, just verify the connection works
|
||||||
|
assert len(tables) >= 0
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_db_session(self, tmp_path):
|
||||||
|
"""Test getting database session."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
engine = create_async_engine(
|
||||||
|
f"sqlite+aiosqlite:///{db_path}",
|
||||||
|
echo=False
|
||||||
|
)
|
||||||
|
async_session = async_sessionmaker(
|
||||||
|
engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
assert isinstance(session, AsyncSession)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
70
backend/tests/unit/test_law_model.py
Normal file
70
backend/tests/unit/test_law_model.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""Unit tests for Law model."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from app.models.law import Law, LawArticle, LawType, LawStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestLawModel:
|
||||||
|
"""Test cases for Law model."""
|
||||||
|
|
||||||
|
def test_law_creation(self):
|
||||||
|
"""Test creating a law instance."""
|
||||||
|
law = Law(
|
||||||
|
title="中华人民共和国民法典",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2020, 5, 28),
|
||||||
|
effective_date=date(2021, 1, 1),
|
||||||
|
status=LawStatus.EFFECTIVE,
|
||||||
|
issuing_authority="全国人民代表大会",
|
||||||
|
content="民法典全文...",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert law.title == "中华人民共和国民法典"
|
||||||
|
assert law.law_type == LawType.LAW
|
||||||
|
assert law.promulgation_date == date(2020, 5, 28)
|
||||||
|
assert law.effective_date == date(2021, 1, 1)
|
||||||
|
assert law.status == LawStatus.EFFECTIVE
|
||||||
|
assert law.issuing_authority == "全国人民代表大会"
|
||||||
|
|
||||||
|
def test_law_type_enum(self):
|
||||||
|
"""Test law type enum values."""
|
||||||
|
assert LawType.LAW.value == "law"
|
||||||
|
assert LawType.REGULATION.value == "regulation"
|
||||||
|
assert LawType.RULE.value == "rule"
|
||||||
|
assert LawType.JUDICIAL_INTERPRETATION.value == "judicial_interpretation"
|
||||||
|
|
||||||
|
def test_law_status_enum(self):
|
||||||
|
"""Test law status enum values."""
|
||||||
|
assert LawStatus.EFFECTIVE.value == "effective"
|
||||||
|
assert LawStatus.REVOKED.value == "revoked"
|
||||||
|
assert LawStatus.AMENDED.value == "amended"
|
||||||
|
|
||||||
|
def test_law_default_values(self):
|
||||||
|
"""Test law default values."""
|
||||||
|
law = Law(
|
||||||
|
title="测试法规",
|
||||||
|
law_type=LawType.REGULATION,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content="测试内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert law.status == LawStatus.EFFECTIVE
|
||||||
|
|
||||||
|
|
||||||
|
class TestLawArticleModel:
|
||||||
|
"""Test cases for LawArticle model."""
|
||||||
|
|
||||||
|
def test_article_creation(self):
|
||||||
|
"""Test creating a law article instance."""
|
||||||
|
article = LawArticle(
|
||||||
|
law_id=1,
|
||||||
|
article_number="第一条",
|
||||||
|
content="为了保护民事主体的合法权益...",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert article.law_id == 1
|
||||||
|
assert article.article_number == "第一条"
|
||||||
|
assert "民事主体" in article.content
|
||||||
144
backend/tests/unit/test_law_service.py
Normal file
144
backend/tests/unit/test_law_service.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""Unit tests for Law service."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.law import Law, LawArticle, LawType, LawStatus
|
||||||
|
from app.services.law_service import LawService
|
||||||
|
|
||||||
|
|
||||||
|
class TestLawService:
|
||||||
|
"""Test cases for Law service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_law(self, db_session: AsyncSession):
|
||||||
|
"""Test creating a law."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
law = await service.create_law(
|
||||||
|
title="测试法律",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content="测试内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert law.id is not None
|
||||||
|
assert law.title == "测试法律"
|
||||||
|
assert law.law_type == LawType.LAW
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_law_by_id(self, db_session: AsyncSession):
|
||||||
|
"""Test getting a law by ID."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
# Create a law first
|
||||||
|
created = await service.create_law(
|
||||||
|
title="测试法律",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content="测试内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the law
|
||||||
|
law = await service.get_law_by_id(created.id)
|
||||||
|
|
||||||
|
assert law is not None
|
||||||
|
assert law.title == "测试法律"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_laws_list(self, db_session: AsyncSession):
|
||||||
|
"""Test getting list of laws."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
# Create multiple laws
|
||||||
|
for i in range(3):
|
||||||
|
await service.create_law(
|
||||||
|
title=f"测试法律{i}",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content=f"测试内容{i}",
|
||||||
|
)
|
||||||
|
|
||||||
|
laws = await service.get_laws_list(skip=0, limit=10)
|
||||||
|
|
||||||
|
assert len(laws) == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_law(self, db_session: AsyncSession):
|
||||||
|
"""Test updating a law."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
# Create a law
|
||||||
|
law = await service.create_law(
|
||||||
|
title="原标题",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content="原内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the law
|
||||||
|
updated = await service.update_law(law.id, title="新标题")
|
||||||
|
|
||||||
|
assert updated.title == "新标题"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_law(self, db_session: AsyncSession):
|
||||||
|
"""Test deleting a law."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
# Create a law
|
||||||
|
law = await service.create_law(
|
||||||
|
title="待删除法律",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="测试机关",
|
||||||
|
content="测试内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the law
|
||||||
|
result = await service.delete_law(law.id)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
# Verify it's deleted
|
||||||
|
deleted = await service.get_law_by_id(law.id)
|
||||||
|
assert deleted is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_laws_by_keyword(self, db_session: AsyncSession):
|
||||||
|
"""Test searching laws by keyword."""
|
||||||
|
service = LawService(db_session)
|
||||||
|
|
||||||
|
# Create laws with different content
|
||||||
|
await service.create_law(
|
||||||
|
title="民法典",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="全国人大",
|
||||||
|
content="民法典全文内容",
|
||||||
|
)
|
||||||
|
await service.create_law(
|
||||||
|
title="刑法",
|
||||||
|
law_type=LawType.LAW,
|
||||||
|
promulgation_date=date(2024, 1, 1),
|
||||||
|
effective_date=date(2024, 2, 1),
|
||||||
|
issuing_authority="全国人大",
|
||||||
|
content="刑法全文内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
results = await service.search_laws_by_keyword("民法")
|
||||||
|
|
||||||
|
assert len(results) >= 1
|
||||||
|
assert any("民法典" in law.title for law in results)
|
||||||
82
backend/tests/unit/test_llm_service.py
Normal file
82
backend/tests/unit/test_llm_service.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""Unit tests for LLM service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from app.services.llm_service import LLMService
|
||||||
|
|
||||||
|
|
||||||
|
class TestLLMService:
|
||||||
|
"""Test cases for LLM service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_no_api_key(self):
|
||||||
|
"""Test chat completion without API key returns mock."""
|
||||||
|
service = LLMService()
|
||||||
|
service.api_key = None # Ensure no API key
|
||||||
|
|
||||||
|
response = await service.chat_completion(
|
||||||
|
messages=[{"role": "user", "content": "你好"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return mock response
|
||||||
|
assert "模拟" in response or "法律" in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_with_api_key(self):
|
||||||
|
"""Test chat completion with API key."""
|
||||||
|
service = LLMService()
|
||||||
|
service.api_key = "test-key"
|
||||||
|
|
||||||
|
with patch('httpx.AsyncClient') as mock_client:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "这是测试回复"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client_instance = MagicMock()
|
||||||
|
mock_client_instance.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
||||||
|
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.return_value = mock_client_instance
|
||||||
|
|
||||||
|
response = await service.chat_completion(
|
||||||
|
messages=[{"role": "user", "content": "你好"}]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "这是测试回复" in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legal_qa(self):
|
||||||
|
"""Test legal QA."""
|
||||||
|
service = LLMService()
|
||||||
|
|
||||||
|
with patch.object(service, 'chat_completion') as mock_chat:
|
||||||
|
mock_chat.return_value = "根据《民法典》第一条规定..."
|
||||||
|
|
||||||
|
response = await service.legal_qa(
|
||||||
|
question="民法典的立法目的是什么?",
|
||||||
|
context="《民法典》第一条 为了保护民事主体的合法权益..."
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "民法典" in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_legal_issue(self):
|
||||||
|
"""Test legal issue analysis."""
|
||||||
|
service = LLMService()
|
||||||
|
|
||||||
|
with patch.object(service, 'chat_completion') as mock_chat:
|
||||||
|
mock_chat.return_value = "分析结果:该问题涉及..."
|
||||||
|
|
||||||
|
response = await service.analyze_legal_issue(
|
||||||
|
issue_description="甲方未按合同约定支付货款",
|
||||||
|
relevant_laws=["《民法典》第五百零九条"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "分析结果" in response
|
||||||
115
backend/tests/unit/test_remaining_models.py
Normal file
115
backend/tests/unit/test_remaining_models.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Unit tests for remaining models and services."""
|
||||||
|
import pytest
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from app.models.analysis import LegalAnalysis, Case, AnalysisStatus
|
||||||
|
from app.models.case import CaseReview, ReviewStatus, ReviewType
|
||||||
|
from app.models.contract import Contract, ContractTemplate, ContractStatus
|
||||||
|
from app.models.signature import SignatureRequest, Signature, SignatureStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalysisModel:
|
||||||
|
"""Test cases for Analysis model."""
|
||||||
|
|
||||||
|
def test_legal_analysis_creation(self):
|
||||||
|
"""Test creating a legal analysis instance."""
|
||||||
|
analysis = LegalAnalysis(
|
||||||
|
user_id=1,
|
||||||
|
title="合同纠纷分析",
|
||||||
|
case_description="甲方未按合同约定支付货款...",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis.user_id == 1
|
||||||
|
assert analysis.title == "合同纠纷分析"
|
||||||
|
assert analysis.status == AnalysisStatus.DRAFT
|
||||||
|
|
||||||
|
def test_case_creation(self):
|
||||||
|
"""Test creating a case instance."""
|
||||||
|
case = Case(
|
||||||
|
title="买卖合同纠纷案",
|
||||||
|
case_number="(2024)京01民初1号",
|
||||||
|
court="北京市第一中级人民法院",
|
||||||
|
case_type="买卖合同纠纷",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert case.title == "买卖合同纠纷案"
|
||||||
|
assert case.case_number == "(2024)京01民初1号"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseReviewModel:
|
||||||
|
"""Test cases for CaseReview model."""
|
||||||
|
|
||||||
|
def test_case_review_creation(self):
|
||||||
|
"""Test creating a case review instance."""
|
||||||
|
review = CaseReview(
|
||||||
|
case_id=1,
|
||||||
|
reviewer_id=1,
|
||||||
|
review_type=ReviewType.INITIAL,
|
||||||
|
opinion="同意立案",
|
||||||
|
score=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert review.case_id == 1
|
||||||
|
assert review.review_type == ReviewType.INITIAL
|
||||||
|
assert review.status == ReviewStatus.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
class TestContractModel:
|
||||||
|
"""Test cases for Contract model."""
|
||||||
|
|
||||||
|
def test_contract_creation(self):
|
||||||
|
"""Test creating a contract instance."""
|
||||||
|
contract = Contract(
|
||||||
|
title="销售合同",
|
||||||
|
party_a="甲方公司",
|
||||||
|
party_b="乙方公司",
|
||||||
|
content="合同内容...",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert contract.title == "销售合同"
|
||||||
|
assert contract.party_a == "甲方公司"
|
||||||
|
assert contract.status == ContractStatus.DRAFT
|
||||||
|
|
||||||
|
def test_contract_template_creation(self):
|
||||||
|
"""Test creating a contract template instance."""
|
||||||
|
template = ContractTemplate(
|
||||||
|
name="标准销售合同模板",
|
||||||
|
content="模板内容...",
|
||||||
|
created_by=1,
|
||||||
|
contract_type="销售合同",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.name == "标准销售合同模板"
|
||||||
|
assert template.contract_type == "销售合同"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignatureModel:
|
||||||
|
"""Test cases for Signature model."""
|
||||||
|
|
||||||
|
def test_signature_request_creation(self):
|
||||||
|
"""Test creating a signature request instance."""
|
||||||
|
request = SignatureRequest(
|
||||||
|
contract_id=1,
|
||||||
|
requester_id=1,
|
||||||
|
signer_name="张三",
|
||||||
|
signer_email="zhangsan@example.com",
|
||||||
|
token="abc123token",
|
||||||
|
expires_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.contract_id == 1
|
||||||
|
assert request.signer_name == "张三"
|
||||||
|
assert request.status == SignatureStatus.PENDING
|
||||||
|
|
||||||
|
def test_signature_creation(self):
|
||||||
|
"""Test creating a signature instance."""
|
||||||
|
signature = Signature(
|
||||||
|
request_id=1,
|
||||||
|
signer_name="张三",
|
||||||
|
signature_data="base64encodedimage",
|
||||||
|
verification_hash="hash123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert signature.request_id == 1
|
||||||
|
assert signature.signer_name == "张三"
|
||||||
84
backend/tests/unit/test_security.py
Normal file
84
backend/tests/unit/test_security.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""Unit tests for security module."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.core.security import (
|
||||||
|
get_password_hash,
|
||||||
|
verify_password,
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordHashing:
|
||||||
|
"""Test cases for password hashing."""
|
||||||
|
|
||||||
|
def test_password_hash(self):
|
||||||
|
"""Test password hashing."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hashed != password
|
||||||
|
assert len(hashed) > 0
|
||||||
|
assert hashed.startswith("$2b$")
|
||||||
|
|
||||||
|
def test_verify_password_correct(self):
|
||||||
|
"""Test verifying correct password."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert verify_password(password, hashed) is True
|
||||||
|
|
||||||
|
def test_verify_password_incorrect(self):
|
||||||
|
"""Test verifying incorrect password."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hashed = get_password_hash(password)
|
||||||
|
|
||||||
|
assert verify_password("wrong_password", hashed) is False
|
||||||
|
|
||||||
|
def test_different_passwords_different_hashes(self):
|
||||||
|
"""Test that same password produces different hashes."""
|
||||||
|
password = "test_password_123"
|
||||||
|
hash1 = get_password_hash(password)
|
||||||
|
hash2 = get_password_hash(password)
|
||||||
|
|
||||||
|
assert hash1 != hash2
|
||||||
|
|
||||||
|
|
||||||
|
class TestJWTTokens:
|
||||||
|
"""Test cases for JWT token handling."""
|
||||||
|
|
||||||
|
def test_create_access_token(self):
|
||||||
|
"""Test creating access token."""
|
||||||
|
data = {"sub": "user123", "role": "lawyer"}
|
||||||
|
token = create_access_token(data)
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_decode_access_token(self):
|
||||||
|
"""Test decoding access token."""
|
||||||
|
data = {"sub": "user123", "role": "lawyer"}
|
||||||
|
token = create_access_token(data)
|
||||||
|
|
||||||
|
decoded = decode_access_token(token)
|
||||||
|
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["sub"] == "user123"
|
||||||
|
assert decoded["role"] == "lawyer"
|
||||||
|
|
||||||
|
def test_decode_invalid_token(self):
|
||||||
|
"""Test decoding invalid token."""
|
||||||
|
decoded = decode_access_token("invalid.token.here")
|
||||||
|
|
||||||
|
assert decoded is None
|
||||||
|
|
||||||
|
def test_token_contains_exp(self):
|
||||||
|
"""Test that token contains expiration."""
|
||||||
|
data = {"sub": "user123"}
|
||||||
|
token = create_access_token(data)
|
||||||
|
|
||||||
|
decoded = decode_access_token(token)
|
||||||
|
|
||||||
|
assert "exp" in decoded
|
||||||
88
backend/tests/unit/test_signature_service.py
Normal file
88
backend/tests/unit/test_signature_service.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for Signature service."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.signature import SignatureStatus
|
||||||
|
from app.models.contract import Contract
|
||||||
|
from app.services.signature_service import SignatureService
|
||||||
|
from app.services.contract_service import ContractService
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignatureService:
|
||||||
|
"""Test cases for Signature service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_token(self, db_session: AsyncSession):
|
||||||
|
"""Test token generation."""
|
||||||
|
service = SignatureService(db_session)
|
||||||
|
|
||||||
|
token = service.generate_token()
|
||||||
|
|
||||||
|
assert token is not None
|
||||||
|
assert len(token) > 20
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_verification_hash(self, db_session: AsyncSession):
|
||||||
|
"""Test verification hash generation."""
|
||||||
|
service = SignatureService(db_session)
|
||||||
|
|
||||||
|
hash1 = service.generate_verification_hash("test content")
|
||||||
|
hash2 = service.generate_verification_hash("test content")
|
||||||
|
hash3 = service.generate_verification_hash("different content")
|
||||||
|
|
||||||
|
assert hash1 == hash2
|
||||||
|
assert hash1 != hash3
|
||||||
|
assert len(hash1) == 64 # SHA-256 hex length
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_signature_request(self, db_session: AsyncSession):
|
||||||
|
"""Test creating a signature request."""
|
||||||
|
# First create a contract
|
||||||
|
contract_service = ContractService(db_session)
|
||||||
|
contract = await contract_service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = SignatureService(db_session)
|
||||||
|
request = await service.create_signature_request(
|
||||||
|
contract_id=contract.id,
|
||||||
|
requester_id=1,
|
||||||
|
signer_name="张三",
|
||||||
|
signer_email="zhangsan@example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert request.id is not None
|
||||||
|
assert request.signer_name == "张三"
|
||||||
|
assert request.status == SignatureStatus.PENDING
|
||||||
|
assert request.token is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_is_request_valid(self, db_session: AsyncSession):
|
||||||
|
"""Test checking if request is valid."""
|
||||||
|
# Create a contract first
|
||||||
|
contract_service = ContractService(db_session)
|
||||||
|
contract = await contract_service.create_contract(
|
||||||
|
title="测试合同",
|
||||||
|
party_a="甲方",
|
||||||
|
party_b="乙方",
|
||||||
|
content="合同内容",
|
||||||
|
created_by=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = SignatureService(db_session)
|
||||||
|
request = await service.create_signature_request(
|
||||||
|
contract_id=contract.id,
|
||||||
|
requester_id=1,
|
||||||
|
signer_name="张三",
|
||||||
|
signer_email="zhangsan@example.com",
|
||||||
|
expire_hours=72,
|
||||||
|
)
|
||||||
|
|
||||||
|
is_valid = await service.is_request_valid(request)
|
||||||
|
assert is_valid is True
|
||||||
52
backend/tests/unit/test_user_model.py
Normal file
52
backend/tests/unit/test_user_model.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"""Unit tests for User model."""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserModel:
|
||||||
|
"""Test cases for User model."""
|
||||||
|
|
||||||
|
def test_user_creation(self):
|
||||||
|
"""Test creating a user instance."""
|
||||||
|
user = User(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
hashed_password="hashed_password",
|
||||||
|
role=UserRole.LAWYER,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.username == "testuser"
|
||||||
|
assert user.email == "test@example.com"
|
||||||
|
assert user.hashed_password == "hashed_password"
|
||||||
|
assert user.role == UserRole.LAWYER
|
||||||
|
assert user.is_active is True
|
||||||
|
|
||||||
|
def test_user_role_enum(self):
|
||||||
|
"""Test user role enum values."""
|
||||||
|
assert UserRole.ADMIN.value == "admin"
|
||||||
|
assert UserRole.LAWYER.value == "lawyer"
|
||||||
|
assert UserRole.REVIEWER.value == "reviewer"
|
||||||
|
assert UserRole.CLIENT.value == "client"
|
||||||
|
|
||||||
|
def test_user_default_values(self):
|
||||||
|
"""Test user default values."""
|
||||||
|
user = User(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
hashed_password="hashed_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.role == UserRole.CLIENT
|
||||||
|
assert user.is_active is True
|
||||||
|
|
||||||
|
def test_user_repr(self):
|
||||||
|
"""Test user string representation."""
|
||||||
|
user = User(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
hashed_password="hashed_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "testuser" in repr(user)
|
||||||
81
backend/tests/unit/test_vector_service.py
Normal file
81
backend/tests/unit/test_vector_service.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Unit tests for Vector service."""
|
||||||
|
import pytest
|
||||||
|
import numpy as np
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
from app.services.vector_service import VectorService
|
||||||
|
|
||||||
|
|
||||||
|
class TestVectorService:
|
||||||
|
"""Test cases for Vector service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_embedding_no_api_key(self):
|
||||||
|
"""Test getting embedding without API key returns mock."""
|
||||||
|
service = VectorService()
|
||||||
|
service.api_key = None # Ensure no API key
|
||||||
|
|
||||||
|
embedding = await service.get_embedding("测试文本")
|
||||||
|
|
||||||
|
# Should return mock embedding
|
||||||
|
assert len(embedding) == service.dimension
|
||||||
|
assert all(x == 0.0 for x in embedding)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_embedding_with_api_key(self):
|
||||||
|
"""Test getting embedding with API key."""
|
||||||
|
service = VectorService()
|
||||||
|
service.api_key = "test-key"
|
||||||
|
|
||||||
|
with patch('httpx.AsyncClient') as mock_client:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"data": [{"embedding": [0.1] * 1536}]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client_instance = MagicMock()
|
||||||
|
mock_client_instance.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_instance.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
||||||
|
mock_client_instance.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.return_value = mock_client_instance
|
||||||
|
|
||||||
|
embedding = await service.get_embedding("测试文本")
|
||||||
|
|
||||||
|
assert len(embedding) == 1536
|
||||||
|
assert all(x == 0.1 for x in embedding)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cosine_similarity(self):
|
||||||
|
"""Test cosine similarity calculation."""
|
||||||
|
service = VectorService()
|
||||||
|
|
||||||
|
vec1 = [1.0, 0.0, 0.0]
|
||||||
|
vec2 = [1.0, 0.0, 0.0]
|
||||||
|
vec3 = [0.0, 1.0, 0.0]
|
||||||
|
|
||||||
|
# Same vectors should have similarity 1.0
|
||||||
|
sim1 = service.cosine_similarity(vec1, vec2)
|
||||||
|
assert abs(sim1 - 1.0) < 0.001
|
||||||
|
|
||||||
|
# Orthogonal vectors should have similarity 0.0
|
||||||
|
sim2 = service.cosine_similarity(vec1, vec3)
|
||||||
|
assert abs(sim2 - 0.0) < 0.001
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_similar(self):
|
||||||
|
"""Test searching similar vectors."""
|
||||||
|
service = VectorService()
|
||||||
|
|
||||||
|
# Mock vectors: first is similar, second is different
|
||||||
|
stored_vectors = [
|
||||||
|
{"id": 1, "embedding": [1.0, 0.0, 0.0]},
|
||||||
|
{"id": 2, "embedding": [0.0, 1.0, 0.0]},
|
||||||
|
]
|
||||||
|
|
||||||
|
query_vec = [0.9, 0.1, 0.0]
|
||||||
|
|
||||||
|
results = service.search_similar(query_vec, stored_vectors, top_k=2)
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]["id"] == 1 # Most similar should be first
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:///./data/legal_assistant.db
|
||||||
|
- LLM_API_KEY=${LLM_API_KEY}
|
||||||
|
- LLM_API_BASE=${LLM_API_BASE:-https://api.openai.com/v1}
|
||||||
|
- LLM_MODEL=${LLM_MODEL:-gpt-4o-mini}
|
||||||
|
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY}
|
||||||
|
- EMBEDDING_API_BASE=${EMBEDDING_API_BASE:-https://api.openai.com/v1}
|
||||||
|
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-text-embedding-3-small}
|
||||||
|
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-in-production}
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
Loading…
x
Reference in New Issue
Block a user