Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Commits listed here are ignored by `git blame` (configure once per clone with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs)
#
# One-time SwiftFormat reformat of the entire Swift source tree -- purely
# mechanical, no logic changes.
4d57122a261e204a6b96c095eb9d9d64a37e9929
32 changes: 30 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,27 @@ jobs:
print(f"Version in sync: {py_version}")
EOF

- name: Ruff (error-tier lint gate)
- name: Ruff (full lint gate)
run: |
pip install ruff
ruff check --select E9,F63,F7,F82 src/python tests
ruff check src/python tests

- name: Help docs in sync (assets/*.md is canonical; regenerate, don't hand-edit the Swift copies)
run: |
python3 scripts/render_help_html.py
# Only the .md fallback copies are tracked -- Resources/*.html is gitignored
# (pure regenerated build output). If the .md copies are fresh, the HTML
# rendering from them is deterministic and therefore fresh too.
tracked=(
"src/swift/MarcutApp/Sources/MarcutApp/Resources/help.md"
"src/swift/MarcutApp/Sources/MarcutApp/Resources/forensics-guide.md"
)
if ! git diff --quiet -- "${tracked[@]}"; then
echo "::error::assets/help.md or assets/forensics-guide.md is out of sync with the generated Swift Resources copies. Edit the assets/*.md source and run 'python3 scripts/render_help_html.py' to regenerate, then commit both the assets/ source and the Swift Resources .md copy."
git diff -- "${tracked[@]}"
exit 1
fi
echo "Help docs in sync."

smoke:
runs-on: macos-14
Expand Down Expand Up @@ -67,3 +84,14 @@ jobs:
mkdir -p src/swift/MarcutApp/Sources/MarcutApp/Frameworks
mkdir -p src/swift/MarcutApp/Sources/MarcutApp/python_site
swift build --package-path src/swift/MarcutApp

- name: SwiftFormat (lint gate)
run: |
# Pinned to an exact release rather than `brew install` -- the runner image's
# pre-cached brew version silently drifts from what contributors have locally,
# and different SwiftFormat versions disagree on some rules (E4 hardening, 2026-07).
curl -sL -o /tmp/swiftformat.zip "https://github.com/nicklockwood/SwiftFormat/releases/download/0.62.1/swiftformat.zip"
unzip -o -q /tmp/swiftformat.zip -d /tmp/swiftformat
chmod +x /tmp/swiftformat/swiftformat
/tmp/swiftformat/swiftformat --version
/tmp/swiftformat/swiftformat --lint src/swift/MarcutApp/Sources src/swift/MarcutApp/Tests
9 changes: 9 additions & 0 deletions .swiftformat
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--swiftversion 5.9
--indent 4
--maxwidth 120
--wraparguments preserve
--wrapparameters preserve
--wrapcollections preserve
--closingparen balanced
--exclude .build,**/Frameworks,**/python_site,**/python_stdlib
--disable redundantSelf
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ Marcut is a native macOS application for legal and professional document redacti

See [Technical Architecture](docs/TECHNICAL_ARCHITECTURE.md) for a system design overview.

<p align="center">
<img src="assets/screenshots/marcut-idle.png" alt="Marcut main window: drag-and-drop area, Browse and Settings buttons, and the redaction/scrub action bar" width="720">
</p>

## 🚀 Key Features

* **Local-First & Private:** All processing happens on your device. No cloud uploads.
Expand All @@ -24,6 +28,18 @@ See [Technical Architecture](docs/TECHNICAL_ARCHITECTURE.md) for a system design
* **Fail-Safe by Design:** Pre-flight checks (writable destination, free disk space), clear plain-English errors, live per-document progress, and fail-closed handling if the AI can't fully analyze a document (no silently incomplete redactions).
* **App Store Ready:** Fully sandboxed and code-signed architecture.

## 📸 Screenshots

| Live AI progress | Redaction settings |
|:---:|:---:|
| <img src="assets/screenshots/marcut-processing.png" alt="Marcut mid-redaction: AI Analysis progress bar, heartbeat status, and estimated time remaining, with a Stop Processing control" width="380"> | <img src="assets/screenshots/marcut-settings.png" alt="Marcut Redaction Settings: processing mode (Rules Only vs Rules + AI), system notifications, output location, and quit-warning behavior" width="380"> |

Redaction streams progress per chunk as the AI model works, with a live heartbeat and time estimate — and finishes with a clear per-document status:

<p align="center">
<img src="assets/screenshots/marcut-finished.png" alt="Marcut after a completed redaction: Finished Processing status with per-document actions (view report, reveal in Finder, share)" width="720">
</p>

## 📖 Documentation

* **[User Guide](docs/USER_GUIDE.md)**: Installation, usage instructions, and troubleshooting.
Expand Down
28 changes: 24 additions & 4 deletions assets/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,11 @@ Notifications

1. Click Redact Documents.
2. Choose an output folder.
3. The app checks the environment (Python runtime and model readiness).
4. Progress stages appear for each document: Loading, Detecting Data, AI Analysis, Validating, Merging, Creating Output.
5. For a batch of 3 or more documents, an estimated-time-remaining figure appears once at least 2 documents have completed, based on the observed processing rate so far in that run. With fewer completed documents there isn't enough data yet, so no estimate is shown.
3. The app runs pre-flight checks: Python runtime, model readiness, that the output folder is writable, and that there is enough free disk space. A failed check is reported upfront (for example a writability or "not enough free disk space" message) instead of failing partway through the run.
4. Progress stages appear for each document: Loading, Detecting Data, AI Analysis, Validating, Merging, Creating Output. During AI Analysis the bar advances continuously as the model streams through each chunk, not only when a whole chunk finishes.
5. For a batch of 3 or more documents, an estimated-time-remaining figure appears once at least 2 documents have completed. The estimate is weighted by each document's word count, so a queue that starts with small/fast documents doesn't produce a misleadingly short figure for larger documents still pending. With fewer completed documents there isn't enough data yet, so no estimate is shown.
6. You can cancel processing with Stop. Stopping is responsive: an active Ollama request or hanging extraction is interrupted promptly rather than waiting for the current document to finish, and no partial output is written for a cancelled document.
7. While any document is processing, Marcut keeps the Mac awake so sleep does not interrupt a long run. If the Mac does sleep and wake mid-run, the AI service is health-checked on wake: processing continues if it responds, or the in-flight document is failed cleanly (with a restart prompt) if it does not.

Metadata-only option
- Use Scrub Metadata to clean metadata without redacting text.
Expand Down Expand Up @@ -892,6 +893,10 @@ Override behaviors:
- Constrained LLM Overrides: AI can drop only ORG/NAME/LOC rule spans when confidence meets `llm_skip_confidence` (default 0.99 in the app; CLI default 0.95).
- LLM Overrides: AI can drop rule spans across labels; use with caution.

Fail-closed on incomplete extraction:
- If the AI extraction for any chunk never succeeds (after its retries), the run fails closed with error code `AI_CHUNK_EXTRACTION_INCOMPLETE`: no output DOCX is written and the report is a failure report naming the character range(s) that were never analyzed.
- This is deliberate for a privacy tool: shipping a "redacted" document that skipped an unanalyzed range would be worse than failing the run. Retry with a smaller chunk size, a different model, or after confirming the AI service is reachable.

LLM tuning controls:
- `temperature`: sampling variability (lower is more deterministic).
- `seed`: helps reproducibility.
Expand Down Expand Up @@ -1175,8 +1180,23 @@ If permission is denied, you can grant it later in macOS System Settings.
- "AI processing timed out"
- Use a smaller model, reduce `chunk-tokens`, or switch to Rules only.

- "The AI could not fully scan this document, so it was not redacted" (`AI_CHUNK_EXTRACTION_INCOMPLETE`)
- Part of the document could not be analyzed by the AI, so Marcut fails the run closed rather than shipping a document with an unscanned range. No output DOCX is written. Try again, reduce `chunk-tokens`, or use a different model. See AI Extraction and Validation for why this fails closed.

- "Cannot write to selected destination"
- Choose a different output folder with write permission.
- Choose a different output folder with write permission. This is checked before the run starts.

- "Not enough free disk space..."
- Free up space or choose a location on a volume with more room. The message states roughly how much is needed versus available. Checked before a redaction run and before a model download.

- "Another Ollama (or other) instance is running on port ..."
- A foreign process was already listening on the port Marcut expected for its embedded AI service. Marcut reports it and tries a different port. If it recurs, quit any separately launched Ollama instance and relaunch Marcut.

- "Processing stalled - the embedded engine stopped responding"
- The embedded AI worker wedged and stopped sending progress. Marcut detects this via an internal heartbeat and fails the document instead of freezing. Restart Marcut and re-run.

- "Processing didn't survive sleep..."
- The Mac slept and the AI service didn't respond after waking. Restart Marcut to resume processing.

- "Python runtime unavailable" (macOS app)
- Restart the app; reinstall if the embedded runtime failed to load.
Expand Down
Binary file added assets/screenshots/marcut-finished.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshots/marcut-idle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshots/marcut-processing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/screenshots/marcut-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/python/marcut/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from .version import __version__
from .version import __version__ as __version__
1 change: 0 additions & 1 deletion src/python/marcut/bootstrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import os
import sys
import json
import subprocess
from pathlib import Path
import tkinter as tk
from tkinter import messagebox
Expand Down
9 changes: 5 additions & 4 deletions src/python/marcut/cli.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse, sys
import argparse
import sys
import json
import os
import shlex
Expand Down Expand Up @@ -185,14 +186,14 @@ def cli_progress_callback(update: ProgressUpdate):
)

if result['success']:
print(f"✅ Redaction completed successfully")
print("✅ Redaction completed successfully")
print(f" Entities detected: {result['entity_count']}")
print(f" Processing time: {result['duration']:.2f}s")

# Show timing breakdown if requested
if (getattr(a, 'timing', False) or getattr(a, 'llm_detail', False)) and 'phase_timings' in result:
total = result['duration']
print(f"\n📊 Phase Timing Breakdown:")
print("\n📊 Phase Timing Breakdown:")
print(f" {'Phase':<20} {'Time':>8} {'Pct':>6}")
print(f" {'-'*20} {'-'*8} {'-'*6}")
for phase, duration in result['phase_timings'].items():
Expand All @@ -207,7 +208,7 @@ def cli_progress_callback(update: ProgressUpdate):
if getattr(a, 'llm_detail', False) and 'llm_timing' in result:
llm = result['llm_timing']
llm_total = llm.get('http_request', 0)
print(f"\n🔬 LLM Sub-Phase Detail:")
print("\n🔬 LLM Sub-Phase Detail:")
print(f" {'Sub-Phase':<22} {'Time':>8} {'Pct':>6}")
print(f" {'-'*22} {'-'*8} {'-'*6}")
sub_phases = [
Expand Down
22 changes: 16 additions & 6 deletions src/python/marcut/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,38 @@ def normalize(entity: str) -> str:
if x.endswith(" " + suf):
x = x[:-(len(suf)+1)]
x = "".join(ch for ch in x if ch.isalnum() or ch.isspace()).strip()
while " " in x: x = x.replace(" "," ")
while " " in x:
x = x.replace(" ", " ")
return x

class ClusterTable:
def __init__(self):
self.next_name = 1; self.next_org = 1; self.next_brand = 1
self.next_name = 1
self.next_org = 1
self.next_brand = 1
self.clusters: Dict[str, List[Dict[str,Any]]] = {"NAME":[], "ORG":[], "BRAND":[]}

def _new_id(self, label: str) -> str:
if label == "NAME":
k = f"NAME_{self.next_name}"; self.next_name += 1; return k
k = f"NAME_{self.next_name}"
self.next_name += 1
return k
if label == "ORG":
k = f"ORG_{self.next_org}"; self.next_org += 1; return k
k = f"BRAND_{self.next_brand}"; self.next_brand += 1; return k
k = f"ORG_{self.next_org}"
self.next_org += 1
return k
k = f"BRAND_{self.next_brand}"
self.next_brand += 1
return k

def link(self, label: str, surface: str) -> Tuple[str, float, bool]:
n = normalize(surface)
best = (None, 0.0)
for cl in self.clusters[label]:
for alias in cl["aliases"]:
s = fuzz.token_set_ratio(n, alias) / 100.0
if s > best[1]: best = (cl, s)
if s > best[1]:
best = (cl, s)
if best[0] and (best[1] >= 0.82 or n in best[0]["aliases"]):
best[0]["aliases"].add(n)
return best[0]["id"], best[1], False
Expand Down
18 changes: 10 additions & 8 deletions src/python/marcut/docx_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from docx.oxml.ns import qn
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Iterable, Tuple
from dataclasses import dataclass, field, fields
from dataclasses import dataclass, fields

def _safe_fromstring(xml_bytes: bytes):
"""Safe XML parsing that disables entity resolution."""
Expand Down Expand Up @@ -487,7 +487,6 @@ def _strip_jpeg_metadata(data: bytes) -> Tuple[bytes, bool]:
if i + 1 >= len(data):
break
length = (data[i] << 8) + data[i + 1]
segment_start = i - 1
segment_end = min(i + length, len(data))
if marker in (0xE1, 0xED): # APP1/APP13
changed = True
Expand Down Expand Up @@ -1383,7 +1382,6 @@ def _append_run(self, para, run):

def _scan_paragraph(self, para):
from docx.text.run import Run
from docx.text.paragraph import Paragraph
from docx.oxml.ns import qn

# Iterate over children to catch runs inside hyperlinks AND drawings (text boxes)
Expand Down Expand Up @@ -1444,7 +1442,8 @@ def _scan_paragraph(self, para):
self._append_run(para, run)
self._scan_run_contents(subchild)

self.text += "\n"; self.index.append(("break", None, None))
self.text += "\n"
self.index.append(("break", None, None))

def _scan_container(self, container):
from docx.text.paragraph import Paragraph
Expand Down Expand Up @@ -1667,7 +1666,8 @@ def _insert_after(self, run: Run, text: str, highlight: bool) -> Run:

def _insert_deletion_after(self, anchor_el, text: str, rPr=None):
del_el = OxmlElement('w:del')
del_el.set(qn('w:id'), str(self._rev_id)); self._rev_id += 1
del_el.set(qn('w:id'), str(self._rev_id))
self._rev_id += 1
del_el.set(qn('w:author'), self.author_name)
del_el.set(qn('w:date'), datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))

Expand All @@ -1685,7 +1685,8 @@ def _insert_deletion_after(self, anchor_el, text: str, rPr=None):

def _insert_insertion_after(self, anchor_el, text: str, rPr=None):
ins_el = OxmlElement('w:ins')
ins_el.set(qn('w:id'), str(self._rev_id)); self._rev_id += 1
ins_el.set(qn('w:id'), str(self._rev_id))
self._rev_id += 1
ins_el.set(qn('w:author'), self.author_name)
ins_el.set(qn('w:date'), datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))
r = self._make_text_run(text, rPr)
Expand Down Expand Up @@ -2054,7 +2055,7 @@ def _remove(tag: str) -> None:
# Define namespace
ns = {'ep': 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties'}

def _remove_elements(tag: str) -> None:
def _remove_elements(tag: str, app_xml=app_xml, ns=ns) -> None:
for elem in app_xml.findall(f'.//ep:{tag}', namespaces=ns):
parent = elem.getparent()
if parent is not None:
Expand Down Expand Up @@ -2363,7 +2364,8 @@ def apply_replacements(self, spans: List[Dict[str,Any]], track_changes: bool = T
cis = sorted(ci for _, _, ci in chars if ci is not None)
if not cis:
continue
start_ci = cis[0]; end_ci = cis[-1]
start_ci = cis[0]
end_ci = cis[-1]
pre = original[:start_ci]
mid = original[start_ci:end_ci+1]
post = original[end_ci+1:]
Expand Down
2 changes: 1 addition & 1 deletion src/python/marcut/docx_revisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def accept_revisions_in_docx_bytes(input_path: str, debug: bool = False) -> Tupl
try:
# Check lxml availability early
try:
import lxml.etree
import lxml.etree # noqa: F401 -- import itself is the availability probe
except ImportError:
return None, False

Expand Down
Loading
Loading