Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Engagement guard
run: python3 scripts/engagement_guard.py

- uses: actions/setup-python@v6
with:
python-version: "3.12"
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ credentials.*
.env.*
*.pem
secrets.*

# --- lailara engagement scaffold ---
# Client engagement data is runtime-only: never commit it, never deploy it.
client-data/
client-output/
/engagement.yml
/engagement.yaml
# (engagement.demo.yml and engagement.example.yml stay committable)
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed
- README "What It Detects" now states **9** date patterns (was 6), matching
`DATE_PATTERNS` in `detection.py` (7 phone / 6 currency were already correct).

### Changed
- Regenerated the committed `samples/output/` reports from current source so the
shipped showcase artifacts reflect the 1.3.0 tool.

## [1.3.0] - 2026-08-05

### Added
- `AuditResult.severity_counts` — a High/Medium/Low breakdown that counts **every**
issue type (findings, duplicates, fuzzy duplicates, schema violations), so it
reconciles to `total_issues`. The findings-only `high_issues` / `medium_issues`
/ `low_issues` properties undercounted, because duplicates and schema violations
carry a severity but are not findings — a severity breakdown drawn from them did
not foot to the headline total. `severity_counts` is the reconciling view.

## [1.2.1] - 2026-07-28

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A single run produces three reports tailored to three audiences: an **HTML repor

## What It Detects

**Mixed Formats** — Identifies dates, phone numbers, and currency values stored in inconsistent formats within the same column. For example, `2023-01-15` alongside `Jan 15, 2023` and `01/15/2023` in one date field. The auditor recognizes 6 date patterns, 7 phone patterns, and 6 currency patterns.
**Mixed Formats** — Identifies dates, phone numbers, and currency values stored in inconsistent formats within the same column. For example, `2023-01-15` alongside `Jan 15, 2023` and `01/15/2023` in one date field. The auditor recognizes 9 date patterns, 7 phone patterns, and 6 currency patterns.

**Misused Fields** — Flags data stored in the wrong column: reference codes in name fields, free text in currency columns, invalid email addresses, and mixed boolean representations (`Y/N` vs `1/0` vs `Active/Inactive` in the same field).

Expand Down
23 changes: 23 additions & 0 deletions data_hygiene_auditor/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,29 @@ def medium_issues(self) -> List[Finding]:
def low_issues(self) -> List[Finding]:
return [f for f in self.findings if f.is_low]

@property
def severity_counts(self) -> Dict[str, int]:
"""Issue counts by severity across EVERY issue type — findings,
duplicates, fuzzy duplicates, and schema violations — so the breakdown
foots to ``total_issues``.

``high_issues`` / ``medium_issues`` / ``low_issues`` are the *findings*
view (Finding objects only); duplicates and schema violations carry a
severity too, so a High/Medium/Low breakdown drawn only from findings
undercounts and does not reconcile to the headline total. This property
is the reconciling breakdown: ``sum(severity_counts.values()) ==
total_issues``.
"""
counts = {"High": 0, "Medium": 0, "Low": 0}
for s in self.sheets:
for collection in (s.findings, s.duplicates, s.fuzzy_duplicates,
s.schema_violations):
for issue in collection:
sev = getattr(issue, "severity", None)
if sev in counts:
counts[sev] += 1
return counts

def to_dict(self) -> Dict[str, Any]:
"""Return the raw audit results dict."""
return self._raw
Expand Down
11 changes: 10 additions & 1 deletion data_hygiene_auditor/reporting/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ def _render_fix(fix: dict[str, str]) -> str:
)


# Static one-liner lifted out of the score-hero f-string so no source line exceeds
# the 120-char lint limit; implicit concatenation reproduces the exact same markup.
_SCORE_SCALE_HTML = (
'<div class="score-scale">Health score, 0&ndash;100 &mdash; '
'90+ clean &middot; 70&ndash;89 needs attention &middot; '
'40&ndash;69 significant issues &middot; below 40 critical</div>'
)


def generate_html(results: dict[str, Any], output_path: str) -> str:
"""Generate a client-readable HTML report."""
counts = count_issues(results)
Expand Down Expand Up @@ -532,7 +541,7 @@ def generate_html(results: dict[str, Any], output_path: str) -> str:
<div class="score-meta">
<div class="score-label">{label}</div>
<div class="score-desc">{score_desc}</div>
<div class="score-scale">Health score, 0&ndash;100 &mdash; 90+ clean &middot; 70&ndash;89 needs attention &middot; 40&ndash;69 significant issues &middot; below 40 critical</div>
{_SCORE_SCALE_HTML}
</div>
</div>

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "data-hygiene-auditor"
version = "1.2.1"
version = "1.3.0"
description = "A linter for your data — detect mixed formats, misused fields, placeholder floods, and phantom duplicates in Excel and CSV files"
readme = "README.md"
license = {text = "MIT"}
Expand Down
Binary file modified samples/output/sample_messy_data_audit_findings.xlsx
Binary file not shown.
26 changes: 16 additions & 10 deletions samples/output/sample_messy_data_audit_report.html
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@
flex-shrink: 0;
}
.score-ring svg { display: block; transform: rotate(-90deg); }
.score-scale {
font-size: 12px;
color: var(--text-secondary, #595959);
margin-top: 6px;
}
.score-ring .score-value {
position: absolute;
top: 50%;
Expand Down Expand Up @@ -414,7 +419,7 @@
<body>

<h1>Data Hygiene Audit Report</h1>
<p class="subtitle">sample_messy_data.xlsx &mdash; 2026-07-27 16:58:34</p>
<p class="subtitle">sample_messy_data.xlsx &mdash; 2026-08-05 20:45:07</p>

<div class="score-hero">
<div class="score-ring">
Expand All @@ -431,6 +436,7 @@ <h1>Data Hygiene Audit Report</h1>
<div class="score-meta">
<div class="score-label">Significant Issues</div>
<div class="score-desc">This dataset has serious quality problems.</div>
<div class="score-scale">Health score, 0&ndash;100 &mdash; 90+ clean &middot; 70&ndash;89 needs attention &middot; 40&ndash;69 significant issues &middot; below 40 critical</div>
</div>
</div>

Expand Down Expand Up @@ -489,7 +495,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
lambda x: &quot;coded&quot; if isinstance(x, str)
and &quot;-&quot; in x else &quot;numeric&quot;
)</pre></div></div></div>
<div class="field-card" data-field="firstname" data-severities="High Low Medium">
<div class="field-card" data-field="firstname" data-severities="Low Medium High">
<div class="field-header">
<span class="field-name">FirstName</span>
<span class="field-type">name</span>
Expand Down Expand Up @@ -522,7 +528,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
)</pre></div></div><div class="issue severity-Medium"><span class="severity-badge Medium">Medium</span> <strong>Suspicious repetition</strong> &mdash; &quot;Doe&quot; appears 3 times (11.5%)<div class="why-box"><strong>Why this matters:</strong> When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (flag_repetitions)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Flag 3 rows where &quot;LastName&quot; = &quot;Doe&quot; (11.5%) for manual review</div><pre class="fix-code">df[&quot;_LastName_review&quot;] = (
df[&quot;LastName&quot;] == &quot;Doe&quot;
)</pre></div></div></div>
<div class="field-card" data-field="email" data-severities="High Low Medium">
<div class="field-card" data-field="email" data-severities="Low Medium High">
<div class="field-header">
<span class="field-name">Email</span>
<span class="field-type">email</span>
Expand All @@ -542,7 +548,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
)</pre></div></div><div class="issue severity-Medium"><span class="severity-badge Medium">Medium</span> <strong>Suspicious repetition</strong> &mdash; &quot;test@test.com&quot; appears 3 times (11.5%)<div class="why-box"><strong>Why this matters:</strong> When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (flag_repetitions)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Flag 3 rows where &quot;Email&quot; = &quot;test@test.com&quot; (11.5%) for manual review</div><pre class="fix-code">df[&quot;_Email_review&quot;] = (
df[&quot;Email&quot;] == &quot;test@test.com&quot;
)</pre></div></div></div>
<div class="field-card" data-field="phone" data-severities="High Low Medium">
<div class="field-card" data-field="phone" data-severities="Low Medium High">
<div class="field-header">
<span class="field-name">Phone</span>
<span class="field-type">phone</span>
Expand All @@ -566,7 +572,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
)</pre></div></div><div class="issue severity-Medium"><span class="severity-badge Medium">Medium</span> <strong>Suspicious repetition</strong> &mdash; &quot;555-555-5555&quot; appears 3 times (11.5%)<div class="why-box"><strong>Why this matters:</strong> When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (flag_repetitions)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Flag 3 rows where &quot;Phone&quot; = &quot;555-555-5555&quot; (11.5%) for manual review</div><pre class="fix-code">df[&quot;_Phone_review&quot;] = (
df[&quot;Phone&quot;] == &quot;555-555-5555&quot;
)</pre></div></div></div>
<div class="field-card" data-field="joindate" data-severities="High Low Medium">
<div class="field-card" data-field="joindate" data-severities="Low Medium High">
<div class="field-header">
<span class="field-name">JoinDate</span>
<span class="field-type">date</span>
Expand All @@ -585,7 +591,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
)</pre></div></div><div class="issue severity-Medium"><span class="severity-badge Medium">Medium</span> <strong>Suspicious repetition</strong> &mdash; &quot;2023-01-15&quot; appears 3 times (11.5%)<div class="why-box"><strong>Why this matters:</strong> When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (flag_repetitions)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Flag 3 rows where &quot;JoinDate&quot; = &quot;2023-01-15&quot; (11.5%) for manual review</div><pre class="fix-code">df[&quot;_JoinDate_review&quot;] = (
df[&quot;JoinDate&quot;] == &quot;2023-01-15&quot;
)</pre></div></div></div>
<div class="field-card" data-field="accountbalance" data-severities="High Low Medium">
<div class="field-card" data-field="accountbalance" data-severities="Low Medium High">
<div class="field-header">
<span class="field-name">AccountBalance</span>
<span class="field-type">currency</span>
Expand All @@ -609,7 +615,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Customers
)</pre></div></div><div class="issue severity-Medium"><span class="severity-badge Medium">Medium</span> <strong>Suspicious repetition</strong> &mdash; &quot;$1,250.00&quot; appears 3 times (11.5%)<div class="why-box"><strong>Why this matters:</strong> When the same value appears far more often than expected, it may indicate a default value that was never updated, a copy-paste error, or a system glitch that stamped the same data across multiple records.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (flag_repetitions)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Flag 3 rows where &quot;AccountBalance&quot; = &quot;$1,250.00&quot; (11.5%) for manual review</div><pre class="fix-code">df[&quot;_AccountBalance_review&quot;] = (
df[&quot;AccountBalance&quot;] == &quot;$1,250.00&quot;
)</pre></div></div></div>
<div class="field-card" data-field="status" data-severities="High Low">
<div class="field-card" data-field="status" data-severities="Low High">
<div class="field-header">
<span class="field-name">Status</span>
<span class="field-type">categorical</span>
Expand Down Expand Up @@ -766,7 +772,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Orders
df[&quot;Amount&quot;].str.replace(r&quot;[^\d.]&quot;, &quot;&quot;, regex=True)
.astype(float)
)</pre></div></div></div>
<div class="field-card" data-field="shipdate" data-severities="High Low">
<div class="field-card" data-field="shipdate" data-severities="Low High">
<div class="field-header">
<span class="field-name">ShipDate</span>
<span class="field-type">date</span>
Expand All @@ -780,7 +786,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Orders
<div style="font-size:0.8rem;color:var(--text-muted);margin:0.2rem 0 0.4rem 0;">6 distinct &nbsp;|&nbsp; 75.0% unique &nbsp;|&nbsp; avg len 9.9</div><div class="issue severity-Low"><span class="severity-badge Low">Low</span> <strong>High missing rate</strong> &mdash; 2 of 10 values missing (20.0%)<div class="why-box"><strong>Why this matters:</strong> High rates of missing data reduce the reliability of any analysis built on this field. Missing values can skew averages, break joins between tables, and cause downstream systems to error out or produce incomplete results.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (fill_missing)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Fill 2 missing values in &quot;ShipDate&quot; (20.0%)</div><pre class="fix-code">df[&quot;ShipDate&quot;] = df[&quot;ShipDate&quot;].fillna(df[&quot;ShipDate&quot;].mode()[0])</pre></div></div><div class="issue severity-High"><span class="severity-badge High">High</span> <strong>Mixed date formats</strong> &mdash; 3 of 8 values deviate from YYYY-MM-DD<table class="format-table"><tr><th>Format</th><th>Count</th></tr><tr><td>YYYY-MM-DD</td><td>5</td></tr><tr><td>MM/DD/YYYY</td><td>1</td></tr><tr><td>Mon DD, YYYY</td><td>1</td></tr><tr><td>M/D/YYYY</td><td>1</td></tr></table><div class="why-box"><strong>Why this matters:</strong> Mixed date formats cause sorting failures, broken filters, and incorrect calculations. A date stored as text (&quot;Jan 15, 2023&quot;) won&#x27;t sort chronologically next to &quot;2023-01-15&quot;. Downstream tools, APIs, and reports will misparse or reject inconsistent dates.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (normalize_dates)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Standardize all dates in &quot;ShipDate&quot; to YYYY-MM-DD format</div><pre class="fix-code">df[&quot;ShipDate&quot;] = pd.to_datetime(
df[&quot;ShipDate&quot;], format=&quot;mixed&quot;, dayfirst=False
).dt.strftime(&quot;%Y-%m-%d&quot;)</pre></div></div></div>
<div class="field-card" data-field="status" data-severities="High Medium">
<div class="field-card" data-field="status" data-severities="Medium High">
<div class="field-header">
<span class="field-name">Status</span>
<span class="field-type">categorical</span>
Expand Down Expand Up @@ -812,7 +818,7 @@ <h2 class="sheet-toggle" onclick="toggleSheet(this)">Sheet: Orders
<tr><th>OrderID</th><th>CustomerID</th><th>OrderDate</th><th>Amount</th><th>ShipDate</th><th>Status</th></tr>
<tr><td>ORD-006</td><td>CUST-010</td><td>2023-01-01</td><td>$0.00</td><td>2023-01-01</td><td>Test</td></tr><tr><td>ORD-007</td><td>CUST-010</td><td>2023-01-01</td><td>$0.00</td><td>2023-01-01</td><td>Test</td></tr></table><div class="why-box"><strong>Why this matters:</strong> Exact duplicate rows are the clearest sign of a data quality issue — they can result from double-submissions, ETL failures, or missing unique constraints. Every duplicate inflates counts and distorts any metric built on this data.</div><div class="fix-block"><div class="fix-header"><span>Suggested Fix (drop_exact_duplicates)</span><button class="fix-copy" onclick="copyFix(this)">Copy</button></div><div class="fix-desc">Remove 2 exact duplicate rows (rows 7, 8)</div><pre class="fix-code">df = df.drop_duplicates(keep=&quot;first&quot;).reset_index(drop=True)</pre></div></div></div></div>
<div class="footer">
Data Hygiene Audit &mdash; Generated 2026-07-27 16:58:34 &mdash; Lailara LLC
Data Hygiene Audit &mdash; Generated 2026-08-05 20:45:07 &mdash; Lailara LLC
</div>

<script>
Expand Down
Binary file modified samples/output/sample_messy_data_audit_report.pdf
Binary file not shown.
Binary file modified samples/output/sample_realistic_data_audit_findings.xlsx
Binary file not shown.
Loading