contractguardian 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """contractguard - AI agent that reads the fine print so you don't have to."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running contractguard as a module: python -m contractguard"""
2
+
3
+ from contractguard.cli import main
4
+
5
+ main()
@@ -0,0 +1,97 @@
1
+ """Core contract analysis engine powered by LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ from openai import OpenAI
9
+ from pydantic import ValidationError
10
+
11
+ from contractguard.models import AnalysisResult
12
+ from contractguard.prompts import get_prompts
13
+
14
+ DEFAULT_MODEL = "anthropic/claude-sonnet-4"
15
+ MAX_CONTRACT_CHARS = 120_000 # ~30K tokens
16
+
17
+
18
+ def get_client(
19
+ api_key: str | None = None,
20
+ base_url: str | None = None,
21
+ ) -> OpenAI:
22
+ """Create an OpenAI-compatible client."""
23
+ api_key = api_key or os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY")
24
+ base_url = base_url or os.environ.get("OPENAI_BASE_URL") or os.environ.get(
25
+ "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
26
+ )
27
+
28
+ if not api_key:
29
+ raise ValueError(
30
+ "No API key found. Set one of:\n"
31
+ " export OPENROUTER_API_KEY=sk-or-...\n"
32
+ " export OPENAI_API_KEY=sk-...\n"
33
+ "Or pass --api-key to the CLI."
34
+ )
35
+
36
+ return OpenAI(api_key=api_key, base_url=base_url)
37
+
38
+
39
+ def analyze_contract(
40
+ contract_text: str,
41
+ model: str = DEFAULT_MODEL,
42
+ api_key: str | None = None,
43
+ base_url: str | None = None,
44
+ lang: str = "en",
45
+ ) -> AnalysisResult:
46
+ """Analyze a contract and return structured results.
47
+
48
+ Args:
49
+ contract_text: The full text of the contract.
50
+ model: The LLM model to use (OpenRouter model ID).
51
+ api_key: API key for the LLM provider.
52
+ base_url: Base URL for the LLM API.
53
+
54
+ Returns:
55
+ AnalysisResult with red flags, warnings, and fairness score.
56
+ """
57
+ if len(contract_text.strip()) < 50:
58
+ raise ValueError("Contract text is too short. Please provide a complete document.")
59
+
60
+ # Truncate if too long
61
+ if len(contract_text) > MAX_CONTRACT_CHARS:
62
+ contract_text = contract_text[:MAX_CONTRACT_CHARS] + "\n\n[... document truncated ...]"
63
+
64
+ client = get_client(api_key=api_key, base_url=base_url)
65
+ system_prompt, analysis_prompt = get_prompts(lang)
66
+
67
+ response = client.chat.completions.create(
68
+ model=model,
69
+ messages=[
70
+ {"role": "system", "content": system_prompt},
71
+ {"role": "user", "content": analysis_prompt.format(contract_text=contract_text)},
72
+ ],
73
+ temperature=0.1,
74
+ max_tokens=4096,
75
+ )
76
+
77
+ content = response.choices[0].message.content.strip()
78
+
79
+ # Strip markdown code block if present
80
+ if content.startswith("```"):
81
+ lines = content.split("\n")
82
+ # Remove first and last lines (```json and ```)
83
+ lines = [line for line in lines if not line.strip().startswith("```")]
84
+ content = "\n".join(lines)
85
+
86
+ try:
87
+ data = json.loads(content)
88
+ except json.JSONDecodeError as e:
89
+ raise RuntimeError(
90
+ f"Failed to parse LLM response as JSON: {e}\n"
91
+ f"Raw response:\n{content[:500]}"
92
+ )
93
+
94
+ try:
95
+ return AnalysisResult(**data)
96
+ except ValidationError as e:
97
+ raise RuntimeError(f"LLM response did not match expected schema: {e}")
contractguard/batch.py ADDED
@@ -0,0 +1,100 @@
1
+ """Batch scanning: analyze several contracts in one run and aggregate the results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from contractguard.models import AnalysisResult
10
+
11
+ # The document types parser.extract_text knows how to read.
12
+ SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".pdf", ".docx", ".txt", ".md", ".rtf"})
13
+
14
+
15
+ @dataclass
16
+ class BatchItem:
17
+ """One contract's place in a batch run: its result, or the error that stopped it."""
18
+
19
+ path: str
20
+ result: AnalysisResult | None = None
21
+ error: str | None = None
22
+
23
+ @property
24
+ def ok(self) -> bool:
25
+ return self.error is None and self.result is not None
26
+
27
+
28
+ @dataclass
29
+ class BatchSummary:
30
+ """Aggregate view across a batch run."""
31
+
32
+ total: int
33
+ succeeded: int
34
+ failed: int
35
+ total_red_flags: int
36
+ total_warnings: int
37
+ grades: dict[str, int] = field(default_factory=dict) # fairness grade -> count
38
+
39
+
40
+ def discover_contracts(path: str | Path) -> list[Path]:
41
+ """Find the contract files to scan.
42
+
43
+ A directory is searched recursively for files whose extension is supported;
44
+ a single file path is returned as-is (so an explicit, oddly-named file is
45
+ still honoured). Results are sorted for deterministic ordering.
46
+ """
47
+ p = Path(path)
48
+ if p.is_dir():
49
+ return sorted(
50
+ f for f in p.rglob("*") if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS
51
+ )
52
+ return [p]
53
+
54
+
55
+ def analyze_paths(
56
+ paths: Iterable[Path],
57
+ analyze_fn: Callable[[Path], AnalysisResult],
58
+ on_result: Callable[[BatchItem], None] | None = None,
59
+ ) -> list[BatchItem]:
60
+ """Run ``analyze_fn`` on each path, isolating per-file failures.
61
+
62
+ A failure on one contract (unreadable file, API error, ...) is captured on
63
+ that ``BatchItem`` and does not abort the rest of the batch — the whole point
64
+ of batch scanning is that one bad file doesn't sink the run. ``on_result`` is
65
+ an optional callback invoked after each item, e.g. to print progress.
66
+ """
67
+ items: list[BatchItem] = []
68
+ for path in paths:
69
+ try:
70
+ item = BatchItem(path=str(path), result=analyze_fn(path))
71
+ except Exception as exc: # noqa: BLE001 - per-file isolation is intentional
72
+ item = BatchItem(path=str(path), error=str(exc))
73
+ items.append(item)
74
+ if on_result is not None:
75
+ on_result(item)
76
+ return items
77
+
78
+
79
+ def summarize_batch(items: list[BatchItem]) -> BatchSummary:
80
+ """Aggregate stats (counts, fairness grades, total issues) across a batch run."""
81
+ grades: dict[str, int] = {}
82
+ total_red_flags = 0
83
+ total_warnings = 0
84
+ succeeded = 0
85
+ for item in items:
86
+ if not item.ok or item.result is None:
87
+ continue
88
+ succeeded += 1
89
+ grade = item.result.fairness_grade
90
+ grades[grade] = grades.get(grade, 0) + 1
91
+ total_red_flags += len(item.result.red_flags)
92
+ total_warnings += len(item.result.warnings)
93
+ return BatchSummary(
94
+ total=len(items),
95
+ succeeded=succeeded,
96
+ failed=len(items) - succeeded,
97
+ total_red_flags=total_red_flags,
98
+ total_warnings=total_warnings,
99
+ grades=grades,
100
+ )
contractguard/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ """Command-line interface for contractguard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from contractguard import __version__
12
+
13
+ console = Console()
14
+
15
+
16
+ @click.group()
17
+ @click.version_option(__version__, prog_name="contractguard")
18
+ def main():
19
+ """AI agent that reads the fine print so you don't have to.
20
+
21
+ Upload any contract and get instant analysis of red flags,
22
+ unfair terms, and missing protections.
23
+ """
24
+ pass
25
+
26
+
27
+ @main.command()
28
+ @click.argument("file", type=click.Path(exists=True))
29
+ @click.option("--model", "-m", default=None, help="LLM model to use (default: anthropic/claude-sonnet-4)")
30
+ @click.option("--api-key", "-k", envvar="OPENROUTER_API_KEY", help="API key (or set OPENROUTER_API_KEY)")
31
+ @click.option("--base-url", "-u", envvar="OPENROUTER_BASE_URL", help="API base URL")
32
+ @click.option("--output", "-o", type=click.Path(), help="Save report to file. Uses JSON when --json is set.")
33
+ @click.option("--json", "json_output", is_flag=True, help="Output raw JSON instead of formatted report")
34
+ @click.option("--lang", "-l", type=click.Choice(["en", "zh"]), default="en", help="Analysis language (en or zh)")
35
+ def scan(file: str, model: str | None, api_key: str | None, base_url: str | None,
36
+ output: str | None, json_output: bool, lang: str):
37
+ """Scan a contract for red flags and unfair terms.
38
+
39
+ Supports PDF, DOCX, and TXT files.
40
+
41
+ \b
42
+ Examples:
43
+ contractguard scan lease.pdf
44
+ contractguard scan contract.docx --model openai/gpt-4o
45
+ contractguard scan nda.txt -o report.md
46
+ """
47
+ from contractguard.analyzer import DEFAULT_MODEL, analyze_contract
48
+ from contractguard.parser import extract_text
49
+ from contractguard.report import print_report
50
+
51
+ model = model or DEFAULT_MODEL
52
+
53
+ # Step 1: Parse document
54
+ with console.status("[bold blue]Parsing document...[/bold blue]"):
55
+ try:
56
+ text = extract_text(file)
57
+ except Exception as e:
58
+ console.print(f"[bold red]Error:[/bold red] {e}")
59
+ sys.exit(1)
60
+
61
+ console.print(f"[green]\u2714[/green] Parsed {Path(file).name} ({len(text):,} characters)")
62
+
63
+ # Step 2: Analyze with LLM
64
+ with console.status(f"[bold blue]Analyzing contract with {model}...[/bold blue]"):
65
+ try:
66
+ result = analyze_contract(
67
+ contract_text=text,
68
+ model=model,
69
+ api_key=api_key,
70
+ base_url=base_url,
71
+ lang=lang,
72
+ )
73
+ except Exception as e:
74
+ console.print(f"[bold red]Error:[/bold red] {e}")
75
+ sys.exit(1)
76
+
77
+ # Step 3: Output results
78
+ if json_output:
79
+ console.print(result.model_dump_json(indent=2))
80
+ else:
81
+ print_report(result)
82
+
83
+ # Step 4: Save report if requested
84
+ if output:
85
+ _write_report(result, output, json_output=json_output)
86
+ console.print(f"\n[green]\u2714[/green] Report saved to {output}")
87
+
88
+
89
+ @main.command()
90
+ @click.argument("path", type=click.Path(exists=True))
91
+ @click.option("--model", "-m", default=None, help="LLM model to use (default: anthropic/claude-sonnet-4)")
92
+ @click.option("--api-key", "-k", envvar="OPENROUTER_API_KEY", help="API key (or set OPENROUTER_API_KEY)")
93
+ @click.option("--base-url", "-u", envvar="OPENROUTER_BASE_URL", help="API base URL")
94
+ @click.option("--lang", "-l", type=click.Choice(["en", "zh"]), default="en", help="Analysis language (en or zh)")
95
+ @click.option("--output-dir", "-o", type=click.Path(), help="Save a markdown report per contract into this directory.")
96
+ def batch(path: str, model: str | None, api_key: str | None, base_url: str | None,
97
+ lang: str, output_dir: str | None):
98
+ """Scan multiple contracts at once: a folder (searched recursively) or a single file.
99
+
100
+ \b
101
+ Examples:
102
+ contractguard batch ./contracts/
103
+ contractguard batch ./contracts/ -o reports/
104
+ """
105
+ from contractguard.analyzer import DEFAULT_MODEL, analyze_contract
106
+ from contractguard.batch import BatchItem, analyze_paths, discover_contracts
107
+ from contractguard.parser import extract_text
108
+ from contractguard.report import generate_markdown_report, print_batch_summary
109
+
110
+ model = model or DEFAULT_MODEL
111
+ paths = discover_contracts(path)
112
+ if not paths:
113
+ console.print("[yellow]No supported contract files found.[/yellow]")
114
+ return
115
+
116
+ console.print(f"[bold blue]Scanning {len(paths)} contract(s)...[/bold blue]")
117
+
118
+ def _analyze(p: Path) -> object:
119
+ return analyze_contract(
120
+ contract_text=extract_text(p),
121
+ model=model,
122
+ api_key=api_key,
123
+ base_url=base_url,
124
+ lang=lang,
125
+ )
126
+
127
+ def _progress(item: BatchItem) -> None:
128
+ name = Path(item.path).name
129
+ if item.ok:
130
+ console.print(f"[green]✔[/green] {name}")
131
+ else:
132
+ console.print(f"[red]✘[/red] {name}: {item.error}")
133
+
134
+ items = analyze_paths(paths, _analyze, on_result=_progress)
135
+ print_batch_summary(items)
136
+
137
+ if output_dir:
138
+ out = Path(output_dir)
139
+ out.mkdir(parents=True, exist_ok=True)
140
+ for item in items:
141
+ if item.result is None:
142
+ continue
143
+ (out / (Path(item.path).stem + ".md")).write_text(
144
+ generate_markdown_report(item.result), encoding="utf-8"
145
+ )
146
+ console.print(f"\n[green]✔[/green] Reports saved to {output_dir}")
147
+
148
+
149
+ @main.command()
150
+ @click.argument("before", type=click.Path(exists=True))
151
+ @click.argument("after", type=click.Path(exists=True))
152
+ @click.option("--model", "-m", default=None, help="LLM model to use (default: anthropic/claude-sonnet-4)")
153
+ @click.option("--api-key", "-k", envvar="OPENROUTER_API_KEY", help="API key (or set OPENROUTER_API_KEY)")
154
+ @click.option("--base-url", "-u", envvar="OPENROUTER_BASE_URL", help="API base URL")
155
+ @click.option("--lang", "-l", type=click.Choice(["en", "zh"]), default="en", help="Analysis language (en or zh)")
156
+ def compare(before: str, after: str, model: str | None, api_key: str | None,
157
+ base_url: str | None, lang: str):
158
+ """Compare two versions of a contract and show what changed.
159
+
160
+ \b
161
+ Examples:
162
+ contractguard compare lease-v1.pdf lease-v2.pdf
163
+ """
164
+ from contractguard.analyzer import DEFAULT_MODEL, analyze_contract
165
+ from contractguard.compare import compare_results
166
+ from contractguard.parser import extract_text
167
+ from contractguard.report import print_comparison
168
+
169
+ model = model or DEFAULT_MODEL
170
+
171
+ def _analyze(path: str, label: str) -> object:
172
+ with console.status(f"[bold blue]Analyzing {label}...[/bold blue]"):
173
+ try:
174
+ return analyze_contract(
175
+ contract_text=extract_text(path),
176
+ model=model,
177
+ api_key=api_key,
178
+ base_url=base_url,
179
+ lang=lang,
180
+ )
181
+ except Exception as e:
182
+ console.print(f"[bold red]Error analyzing {label}:[/bold red] {e}")
183
+ sys.exit(1)
184
+
185
+ before_result = _analyze(before, Path(before).name)
186
+ after_result = _analyze(after, Path(after).name)
187
+ print_comparison(compare_results(before_result, after_result))
188
+
189
+
190
+ @main.command()
191
+ def web():
192
+ """Launch the web UI (requires: pip install contractguard[web])."""
193
+ try:
194
+ from contractguard.web import main as launch
195
+ except ImportError:
196
+ console.print("[red]Gradio not installed.[/red] Run: pip install contractguard[web]")
197
+ return
198
+ launch()
199
+
200
+
201
+ def _write_report(result, output: str, json_output: bool = False) -> None:
202
+ from contractguard.report import generate_markdown_report
203
+
204
+ if json_output:
205
+ content = result.model_dump_json(indent=2) + "\n"
206
+ else:
207
+ content = generate_markdown_report(result)
208
+ output_path = Path(output)
209
+ output_path.parent.mkdir(parents=True, exist_ok=True)
210
+ output_path.write_text(content, encoding="utf-8")
@@ -0,0 +1,56 @@
1
+ """Contract comparison: diff two analyzed versions of the same contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from contractguard.models import AnalysisResult, Issue
8
+
9
+
10
+ @dataclass
11
+ class ContractComparison:
12
+ """What changed between an earlier and a later version of a contract."""
13
+
14
+ score_before: int
15
+ score_after: int
16
+ grade_before: str
17
+ grade_after: str
18
+ added_red_flags: list[Issue] = field(default_factory=list)
19
+ resolved_red_flags: list[Issue] = field(default_factory=list)
20
+ added_warnings: list[Issue] = field(default_factory=list)
21
+ resolved_warnings: list[Issue] = field(default_factory=list)
22
+
23
+ @property
24
+ def score_delta(self) -> int:
25
+ """Positive when the later version is fairer."""
26
+ return self.score_after - self.score_before
27
+
28
+
29
+ def _diff_by_title(before: list[Issue], after: list[Issue]) -> tuple[list[Issue], list[Issue]]:
30
+ """Match issues by title and return ``(added, resolved)``.
31
+
32
+ Added = present in *after* but not *before*; resolved = present in *before*
33
+ but not *after*. Issues carried over unchanged appear in neither list.
34
+ """
35
+ before_titles = {issue.title for issue in before}
36
+ after_titles = {issue.title for issue in after}
37
+ added = [issue for issue in after if issue.title not in before_titles]
38
+ resolved = [issue for issue in before if issue.title not in after_titles]
39
+ return added, resolved
40
+
41
+
42
+ def compare_results(before: AnalysisResult, after: AnalysisResult) -> ContractComparison:
43
+ """Diff two analysis results: which issues were added or resolved, and how the
44
+ fairness score moved between the two versions."""
45
+ added_red, resolved_red = _diff_by_title(before.red_flags, after.red_flags)
46
+ added_warn, resolved_warn = _diff_by_title(before.warnings, after.warnings)
47
+ return ContractComparison(
48
+ score_before=before.fairness_score,
49
+ score_after=after.fairness_score,
50
+ grade_before=before.fairness_grade,
51
+ grade_after=after.fairness_grade,
52
+ added_red_flags=added_red,
53
+ resolved_red_flags=resolved_red,
54
+ added_warnings=added_warn,
55
+ resolved_warnings=resolved_warn,
56
+ )
@@ -0,0 +1,70 @@
1
+ """Data models for contract analysis results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class Severity(str, Enum):
11
+ """Severity level of an identified issue."""
12
+
13
+ RED = "red"
14
+ YELLOW = "yellow"
15
+ GREEN = "green"
16
+
17
+
18
+ class ContractType(str, Enum):
19
+ """Supported contract types."""
20
+
21
+ LEASE = "lease"
22
+ NDA = "nda"
23
+ EMPLOYMENT = "employment"
24
+ FREELANCE = "freelance"
25
+ SaaS = "saas_tos"
26
+ LOAN = "loan"
27
+ PURCHASE = "purchase"
28
+ UNKNOWN = "unknown"
29
+
30
+
31
+ class Issue(BaseModel):
32
+ """A single identified issue in the contract."""
33
+
34
+ title: str = Field(description="Short title of the issue")
35
+ severity: Severity = Field(description="Severity: red (danger), yellow (warning), green (ok)")
36
+ clause: str = Field(description="The relevant clause or section reference")
37
+ quote: str = Field(description="Direct quote from the contract")
38
+ explanation: str = Field(description="Plain-language explanation of the issue")
39
+ suggestion: str = Field(description="Suggested modification or action")
40
+
41
+
42
+ class Protection(BaseModel):
43
+ """A positive protection found in the contract."""
44
+
45
+ title: str = Field(description="What is protected")
46
+ clause: str = Field(description="The relevant clause or section")
47
+ explanation: str = Field(description="Why this protection matters")
48
+
49
+
50
+ class AnalysisResult(BaseModel):
51
+ """Complete analysis result for a contract."""
52
+
53
+ contract_type: ContractType = Field(description="Detected type of contract")
54
+ summary: str = Field(description="2-3 sentence plain-language summary of the contract")
55
+ parties: list[str] = Field(default_factory=list, description="Parties involved")
56
+ key_terms: list[str] = Field(
57
+ default_factory=list, description="Key terms (duration, payment, etc.)"
58
+ )
59
+ red_flags: list[Issue] = Field(default_factory=list, description="Serious issues found")
60
+ warnings: list[Issue] = Field(default_factory=list, description="Moderate concerns")
61
+ good_clauses: list[Protection] = Field(
62
+ default_factory=list, description="Positive protections found"
63
+ )
64
+ missing_protections: list[str] = Field(
65
+ default_factory=list, description="Important protections not found in the contract"
66
+ )
67
+ fairness_score: int = Field(
68
+ ge=0, le=100, description="Overall fairness score from 0 (terrible) to 100 (excellent)"
69
+ )
70
+ fairness_grade: str = Field(description="Letter grade: A+, A, B+, B, C+, C, D, F")
@@ -0,0 +1,65 @@
1
+ """Document parsing: extract text from PDF, DOCX, TXT, and image files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def extract_text(file_path: str | Path) -> str:
9
+ """Extract text content from a document file.
10
+
11
+ Supports: .pdf, .docx, .txt, .md, .rtf
12
+ """
13
+ path = Path(file_path)
14
+ if not path.exists():
15
+ raise FileNotFoundError(f"File not found: {path}")
16
+
17
+ suffix = path.suffix.lower()
18
+
19
+ if suffix == ".pdf":
20
+ return _extract_pdf(path)
21
+ elif suffix == ".docx":
22
+ return _extract_docx(path)
23
+ elif suffix in (".txt", ".md", ".rtf"):
24
+ return path.read_text(encoding="utf-8", errors="replace")
25
+ else:
26
+ raise ValueError(
27
+ f"Unsupported file type: {suffix}. "
28
+ f"Supported: .pdf, .docx, .txt, .md, .rtf"
29
+ )
30
+
31
+
32
+ def _extract_pdf(path: Path) -> str:
33
+ """Extract text from a PDF file."""
34
+ try:
35
+ import pdfplumber
36
+ except ImportError:
37
+ raise ImportError("pdfplumber is required for PDF files: pip install pdfplumber")
38
+
39
+ text_parts = []
40
+ with pdfplumber.open(path) as pdf:
41
+ for page in pdf.pages:
42
+ page_text = page.extract_text()
43
+ if page_text:
44
+ text_parts.append(page_text)
45
+
46
+ if not text_parts:
47
+ raise ValueError(
48
+ f"Could not extract text from PDF: {path}. "
49
+ "The PDF may be scanned or image-based. OCR support is not available yet; "
50
+ "please use a text-based PDF, DOCX, TXT, MD, or RTF file."
51
+ )
52
+
53
+ return "\n\n".join(text_parts)
54
+
55
+
56
+ def _extract_docx(path: Path) -> str:
57
+ """Extract text from a DOCX file."""
58
+ try:
59
+ from docx import Document
60
+ except ImportError:
61
+ raise ImportError("python-docx is required for DOCX files: pip install python-docx")
62
+
63
+ doc = Document(str(path))
64
+ paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
65
+ return "\n\n".join(paragraphs)