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.
- contractguard/__init__.py +3 -0
- contractguard/__main__.py +5 -0
- contractguard/analyzer.py +97 -0
- contractguard/batch.py +100 -0
- contractguard/cli.py +210 -0
- contractguard/compare.py +56 -0
- contractguard/models.py +70 -0
- contractguard/parser.py +65 -0
- contractguard/prompts.py +140 -0
- contractguard/report.py +303 -0
- contractguard/web.py +154 -0
- contractguardian-0.1.0.dist-info/METADATA +468 -0
- contractguardian-0.1.0.dist-info/RECORD +16 -0
- contractguardian-0.1.0.dist-info/WHEEL +4 -0
- contractguardian-0.1.0.dist-info/entry_points.txt +2 -0
- contractguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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")
|
contractguard/compare.py
ADDED
|
@@ -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
|
+
)
|
contractguard/models.py
ADDED
|
@@ -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")
|
contractguard/parser.py
ADDED
|
@@ -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)
|