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
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ updates:
time: "07:00"
timezone: Europe/London
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
update-types:
- version-update:semver-major
groups:
production-dependencies:
dependency-type: production
Expand Down Expand Up @@ -35,3 +39,7 @@ updates:
time: "07:30"
timezone: Europe/London
open-pull-requests-limit: 3
ignore:
- dependency-name: "node"
update-types:
- version-update:semver-major
74 changes: 72 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ jobs:
- name: Verify LaTeX runtime
run: docker exec modelhawk-ci pdflatex --version

- name: Exercise audit and PDF endpoints
- name: Exercise audit, examiner and PDF endpoints
shell: bash
run: |
set -euo pipefail
Expand Down Expand Up @@ -109,9 +109,45 @@ jobs:

if len(audit.get('findings', [])) < 4:
raise SystemExit('Expected the bundled regression fixture to produce at least four findings')
PY

curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data-binary @/tmp/modelhawk-audit.json \
http://127.0.0.1:3000/api/audit/ai \
-o /tmp/modelhawk-ai.json

python - <<'PY'
import json

with open('/tmp/modelhawk-audit.json', encoding='utf-8') as handle:
audit = json.load(handle)
with open('/tmp/modelhawk-ai.json', encoding='utf-8') as handle:
examiner = json.load(handle)

if examiner.get('provider') != 'deterministic':
raise SystemExit(f"Expected deterministic CI examiner, got {examiner.get('provider')}")

narrative = examiner.get('aiNarrative')
required = {
'executiveSummary',
'technicalAssessment',
'riskInterpretation',
'portfolioVerdict',
'improvedRecommendations',
'suggestedMarkdownCells',
'vivaAnswers',
'limitations',
}

if not isinstance(narrative, dict) or not required.issubset(narrative):
raise SystemExit('Examiner endpoint returned an incomplete controlled narrative')

if len(narrative.get('vivaAnswers', [])) != len(audit.get('vivaQuestions', [])):
raise SystemExit('Examiner did not preserve one answer per deterministic viva question')

with open('/tmp/modelhawk-pdf-payload.json', 'w', encoding='utf-8') as handle:
json.dump({'auditResult': audit}, handle)
json.dump({'auditResult': audit, 'aiNarrative': narrative}, handle)
PY

curl --fail --silent --show-error \
Expand All @@ -137,6 +173,40 @@ jobs:
print(f'PDF smoke test passed: {len(report)} bytes')
PY

- name: Verify invalid API requests fail closed
shell: bash
run: |
set -euo pipefail

printf 'not a notebook' >/tmp/modelhawk-invalid.txt
status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-audit.json -w '%{http_code}' \
-F "file=@/tmp/modelhawk-invalid.txt;filename=invalid.txt" \
http://127.0.0.1:3000/api/audit)
test "$status" = "400"
grep -q 'Please upload a .ipynb notebook file.' /tmp/modelhawk-invalid-audit.json

status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-ai.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
--data '{"fileName":"invalid.ipynb","scores":{"leakageRisk":999,"metricQuality":1,"reproducibility":1,"portfolioReadiness":1}}' \
http://127.0.0.1:3000/api/audit/ai)
test "$status" = "400"

status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-pdf.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
--data '{"fileName":"invalid.ipynb"}' \
http://127.0.0.1:3000/api/report/pdf)
test "$status" = "400"

- name: Verify runtime security headers
shell: bash
run: |
set -euo pipefail
curl --silent --show-error --head http://127.0.0.1:3000/ >/tmp/modelhawk-headers.txt
grep -qi '^content-security-policy:' /tmp/modelhawk-headers.txt
grep -qi '^strict-transport-security:' /tmp/modelhawk-headers.txt
grep -qi '^x-content-type-options: nosniff' /tmp/modelhawk-headers.txt
grep -qi '^x-frame-options: DENY' /tmp/modelhawk-headers.txt

- name: Show container logs on failure
if: failure()
run: docker logs modelhawk-ci || true
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Useful routes:
- `/upload` — notebook upload workflow
- `/api/health` — deployment health and revision endpoint

The public instance currently runs on Render's free web-service tier. It can spin down after inactivity, so the first request after an idle period may be noticeably slower. The application itself is containerised and the same Docker image is exercised in CI before merge. Render is configured to deploy from `main` only after the GitHub checks pass.
The public instance currently runs on Render's free web-service tier. It can spin down after inactivity, so the first request after an idle period may briefly show Render's loading screen while the service wakes. The application itself is containerised and the same Docker image is exercised in CI before merge. Render is configured to deploy from `main` only after the GitHub checks pass.

The public deployment does not require a hosted AI-provider key. If no external provider is configured, the examiner layer uses the deterministic fallback path.

Expand Down
6 changes: 3 additions & 3 deletions app/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ export default function DemoPage() {
</a>
</div>
<p className="mt-4 max-w-3xl text-xs leading-5 text-slate-500">
The previous sample PDF has been removed from this page because it
was generated before the hardened audit rules. A replacement will
only be published after it is regenerated from the current engine.
PDF reports are generated on demand from the current deterministic
audit result, so exported reports stay aligned with the hardened
rules used by the live application.
</p>
</div>

Expand Down
52 changes: 41 additions & 11 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,53 @@
import type { Metadata } from "next";
import "./globals.css";

const description =
"Static ML notebook auditing with deterministic methodology checks, AI-assisted review and production PDF reports.";

export const metadata: Metadata = {
title: "ModelHawk",
description:
"Adversarial examiner for machine learning notebooks, metrics and project defense.",
icons: {
icon: "/modelhawk-favicon.png",
metadataBase: new URL("https://modelhawk.onrender.com"),
title: {
default: "ModelHawk | Static ML Notebook Auditor",
template: "%s | ModelHawk",
},
description,
applicationName: "ModelHawk",
keywords: [
"machine learning",
"ML audit",
"Jupyter notebook",
"model evaluation",
"data leakage",
"reproducibility",
],
authors: [{ name: "Alberto Reyes Castro" }],
creator: "Alberto Reyes Castro",
openGraph: {
type: "website",
url: "/",
title: "ModelHawk | Static ML Notebook Auditor",
description,
siteName: "ModelHawk",
images: [{
url: "/docs/screenshots/01-landing-page.png",
width: 1440,
height: 1000,
alt: "ModelHawk static ML notebook auditor",
}],
},
twitter: {
card: "summary_large_image",
title: "ModelHawk | Static ML Notebook Auditor",
description,
images: ["/docs/screenshots/01-landing-page.png"],
},
icons: { icon: "/modelhawk-favicon.png" },
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
}
34 changes: 17 additions & 17 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ const checks = [
label: "Scoring",
},
{
title: "AI Defense Report",
title: "Controlled Examiner Report",
description:
"Use AI to explain the findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",
label: "AI layer",
"Use a controlled examiner layer — AI-assisted when configured — to explain findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",
label: "Examiner layer",
},
];

Expand Down Expand Up @@ -162,8 +162,8 @@ export default function Home() {
<p className="mt-5 max-w-2xl text-base leading-7 text-slate-400">
ModelHawk does not simply ask an AI to “review your notebook”. It
first extracts objective signals from your project, runs targeted ML
checks, scores the risks and then uses AI to explain the findings
clearly.
checks, scores the risks and then uses a controlled examiner layer
to explain the findings, with external AI only when configured.
</p>

<div className="mt-9 flex flex-col gap-4 sm:flex-row">
Expand Down Expand Up @@ -206,25 +206,25 @@ export default function Home() {
</div>

<div className="rounded-full bg-orange-500/15 px-4 py-2 text-sm font-semibold text-orange-300">
Medium-high risk
High leakage risk
</div>
</div>

<div className="grid gap-4">
<AuditRow label="Leakage risk" value={72} />
<AuditRow label="Metric quality" value={64} />
<AuditRow label="Reproducibility" value={58} />
<AuditRow label="Portfolio readiness" value={81} />
<AuditRow label="Leakage risk" value={75} />
<AuditRow label="Metric quality" value={76} />
<AuditRow label="Reproducibility" value={90} />
<AuditRow label="Portfolio readiness" value={79} />
</div>

<div className="mt-6 rounded-2xl border border-blue-400/20 bg-blue-500/10 p-4">
<p className="text-sm font-semibold text-blue-200">
Hawk finding
</p>
<p className="mt-2 text-sm leading-6 text-slate-300">
Accuracy is reported as a main metric, but the dataset appears
imbalanced. Add recall, precision, F1, PR-AUC and threshold
justification.
Multiple candidate model families appear to be evaluated on
held-out test data before final selection. Keep the test set
reserved for one final unbiased evaluation.
</p>
</div>

Expand All @@ -244,16 +244,16 @@ export default function Home() {
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-300">
<span className="rounded-full bg-blue-500/10 px-3 py-1">
uses accuracy
test-set selection risk
</span>
<span className="rounded-full bg-blue-500/10 px-3 py-1">
no threshold tuning
test-set threshold tuning risk
</span>
<span className="rounded-full bg-blue-500/10 px-3 py-1">
imbalanced problem
preprocessing outside CV risk
</span>
<span className="rounded-full bg-blue-500/10 px-3 py-1">
weak reproducibility
custom composite score
</span>
</div>
</div>
Expand Down
15 changes: 8 additions & 7 deletions app/upload/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export default function UploadPage() {
<section className="relative z-10 mx-auto grid max-w-7xl items-start gap-12 px-6 py-16 lg:grid-cols-[0.85fr_1.15fr]">
<div>
<div className="mb-6 inline-flex rounded-full border border-blue-400/30 bg-blue-500/10 px-4 py-2 text-sm text-blue-100">
Static audit + AI examiner
Static audit + controlled examiner
</div>

<h1 className="text-5xl font-black tracking-tight md:text-7xl">
Expand All @@ -243,14 +243,15 @@ export default function UploadPage() {

<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
ModelHawk reads your notebook, detects the project profile, applies
technical audit checks and then uses an AI examiner layer to
generate richer project-specific explanations.
technical audit checks and then uses a controlled examiner layer to
generate richer project-specific explanations, with external AI only
when a provider is configured.
</p>

<div className="mt-8 grid gap-3 text-sm text-slate-400">
<InfoBox text="This version does not execute your notebook. It performs static analysis." />
<InfoBox text="It detects project type, data modality, ML signals, risks, good practices and suggested notebook improvements." />
<InfoBox text="The AI examiner notes are generated by the configured AI provider using the audit result as evidence." />
<InfoBox text="Examiner notes are grounded in the audit result. If no external AI provider is configured, ModelHawk uses its deterministic fallback examiner." />
</div>
</div>

Expand All @@ -264,8 +265,8 @@ export default function UploadPage() {

<p className="mt-3 text-sm leading-6 text-slate-400">
Upload a Jupyter notebook file. ModelHawk will parse it, return
technical signals, generate risk scores and add AI examiner
notes.
technical signals, generate risk scores and add controlled
examiner notes.
</p>

<label className="mt-8 flex cursor-pointer flex-col items-center justify-center rounded-3xl border border-dashed border-blue-300/40 bg-blue-500/10 p-10 text-center transition hover:border-cyan-300/70 hover:bg-blue-500/15">
Expand Down Expand Up @@ -315,7 +316,7 @@ export default function UploadPage() {
{loading
? "Analysing notebook..."
: aiLoading
? "Generating AI examiner notes..."
? "Generating examiner notes..."
: "Run static audit"}
</button>

Expand Down
27 changes: 21 additions & 6 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import type { NextConfig } from "next";

const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline'",
"connect-src 'self'",
"worker-src 'self' blob:",
"manifest-src 'self'",
"upgrade-insecure-requests",
].join("; ");

const SECURITY_HEADERS = [
{ key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-DNS-Prefetch-Control", value: "off" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
];

const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
async headers() {
return [
{
source: "/(.*)",
headers: SECURITY_HEADERS,
},
];
return [{ source: "/(.*)", headers: SECURITY_HEADERS }];
},
};

Expand Down
Binary file modified public/docs/screenshots/01-landing-page.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 modified public/docs/screenshots/02-upload-page.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 modified public/docs/screenshots/03-audit-results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading