From 0fa90b3c22465acacf70bbc3a6c4afef9d9e9566 Mon Sep 17 00:00:00 2001 From: Alberto Reyes Castro Date: Fri, 23 Oct 2026 15:03:34 -0400 Subject: [PATCH 1/9] Run one-off final consistency polish --- .github/workflows/zz-final-polish.yml | 128 ++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/zz-final-polish.yml diff --git a/.github/workflows/zz-final-polish.yml b/.github/workflows/zz-final-polish.yml new file mode 100644 index 0000000..b90b885 --- /dev/null +++ b/.github/workflows/zz-final-polish.yml @@ -0,0 +1,128 @@ +name: One-off final consistency polish + +on: + push: + branches: + - polish/final-consistency-20261023 + +permissions: + contents: write + +jobs: + polish: + runs-on: ubuntu-latest + steps: + - name: Check out branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: polish/final-consistency-20261023 + + - name: Apply final recruiter and reliability polish + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_exact(path, old, new, expected=1): + p = Path(path) + text = p.read_text(encoding='utf-8') + count = text.count(old) + if count != expected: + raise SystemExit(f'{path}: expected {expected} occurrence(s), found {count}: {old!r}') + p.write_text(text.replace(old, new), encoding='utf-8') + + # Landing: align the visual sample and examiner wording with the live deterministic fixture. + replace_exact('app/page.tsx', 'title: "AI Defense Report",', 'title: "Controlled Examiner Report",') + replace_exact('app/page.tsx', 'label: "AI layer",', 'label: "Examiner layer",') + replace_exact( + 'app/page.tsx', + ' "Use AI to explain the findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",', + ' "Use a controlled examiner layer — AI-assisted when configured — to explain findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",' + ) + replace_exact( + 'app/page.tsx', + ' checks, scores the risks and then uses AI to explain the findings\n clearly.', + ' checks, scores the risks and then uses a controlled examiner layer\n to explain the findings, with external AI only when configured.' + ) + replace_exact('app/page.tsx', 'Medium-high risk', 'High leakage risk') + replace_exact('app/page.tsx', '', '') + replace_exact('app/page.tsx', '', '') + replace_exact('app/page.tsx', '', '') + replace_exact('app/page.tsx', '', '') + replace_exact( + 'app/page.tsx', + ' Accuracy is reported as a main metric, but the dataset appears\n imbalanced. Add recall, precision, F1, PR-AUC and threshold\n justification.', + ' Multiple candidate model families appear to be evaluated on\n held-out test data before final selection. Keep the test set\n reserved for one final unbiased evaluation.' + ) + replace_exact('app/page.tsx', ' uses accuracy', ' test-set selection risk') + replace_exact('app/page.tsx', ' no threshold tuning', ' test-set threshold tuning risk') + replace_exact('app/page.tsx', ' imbalanced problem', ' preprocessing outside CV risk') + replace_exact('app/page.tsx', ' weak reproducibility', ' custom composite score') + + # Demo: remove the stale promise about a future PDF replacement. + replace_exact( + 'app/demo/page.tsx', + ' The previous sample PDF has been removed from this page because it\n was generated before the hardened audit rules. A replacement will\n only be published after it is regenerated from the current engine.', + ' PDF reports are generated on demand from the current deterministic\n audit result, so exported reports stay aligned with the hardened\n rules used by the live application.' + ) + + # Upload: accurately describe deterministic fallback when no provider is configured. + replace_exact('app/upload/page.tsx', 'Static audit + AI examiner', 'Static audit + controlled examiner') + replace_exact( + 'app/upload/page.tsx', + ' technical audit checks and then uses an AI examiner layer to\n generate richer project-specific explanations.', + ' technical audit checks and then uses a controlled examiner layer to\n generate richer project-specific explanations, with external AI only\n when a provider is configured.' + ) + replace_exact( + 'app/upload/page.tsx', + '', + '' + ) + replace_exact( + 'app/upload/page.tsx', + ' technical signals, generate risk scores and add AI examiner\n notes.', + ' technical signals, generate risk scores and add controlled\n examiner notes.' + ) + replace_exact('app/upload/page.tsx', '"Generating AI examiner notes..."', '"Generating examiner notes..."') + + # SEO/social metadata: align the live site with the recruiter-facing repository description. + Path('app/layout.tsx').write_text('''import type { Metadata } from "next";\nimport "./globals.css";\n\nconst description =\n "Static ML notebook auditing with deterministic methodology checks, AI-assisted review and production PDF reports.";\n\nexport const metadata: Metadata = {\n metadataBase: new URL("https://modelhawk.onrender.com"),\n title: {\n default: "ModelHawk | Static ML Notebook Auditor",\n template: "%s | ModelHawk",\n },\n description,\n applicationName: "ModelHawk",\n keywords: [\n "machine learning",\n "ML audit",\n "Jupyter notebook",\n "model evaluation",\n "data leakage",\n "reproducibility",\n ],\n authors: [{ name: "Alberto Reyes Castro" }],\n creator: "Alberto Reyes Castro",\n openGraph: {\n type: "website",\n url: "/",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n siteName: "ModelHawk",\n images: [\n {\n url: "/docs/screenshots/01-landing-page.png",\n width: 1440,\n height: 1000,\n alt: "ModelHawk static ML notebook auditor",\n },\n ],\n },\n twitter: {\n card: "summary_large_image",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n images: ["/docs/screenshots/01-landing-page.png"],\n },\n icons: {\n icon: "/modelhawk-favicon.png",\n },\n};\n\nexport default function RootLayout({\n children,\n}: Readonly<{\n children: React.ReactNode;\n}>) {\n return (\n \n {children}\n \n );\n}\n''', encoding='utf-8') + + # Low-risk production security headers. CSP remains compatible with Next's inline bootstrap/styles. + Path('next.config.ts').write_text('''import type { NextConfig } from "next";\n\nconst CONTENT_SECURITY_POLICY = [\n "default-src 'self'",\n "base-uri 'self'",\n "form-action 'self'",\n "frame-ancestors 'none'",\n "object-src 'none'",\n "img-src 'self' data: blob:",\n "font-src 'self' data:",\n "style-src 'self' 'unsafe-inline'",\n "script-src 'self' 'unsafe-inline'",\n "connect-src 'self'",\n "worker-src 'self' blob:",\n "manifest-src 'self'",\n "upgrade-insecure-requests",\n].join("; ");\n\nconst SECURITY_HEADERS = [\n { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY },\n { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },\n { key: "X-Content-Type-Options", value: "nosniff" },\n { key: "X-Frame-Options", value: "DENY" },\n { key: "X-DNS-Prefetch-Control", value: "off" },\n { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },\n { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },\n { key: "Cross-Origin-Opener-Policy", value: "same-origin" },\n { key: "Cross-Origin-Resource-Policy", value: "same-origin" },\n];\n\nconst nextConfig: NextConfig = {\n output: "standalone",\n poweredByHeader: false,\n async headers() {\n return [\n {\n source: "/(.*)",\n headers: SECURITY_HEADERS,\n },\n ];\n },\n};\n\nexport default nextConfig;\n''', encoding='utf-8') + + # README: accurately describe the free-tier wake-up UX. + replace_exact( + 'README.md', + "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." + ) + + # Add cheap regression coverage for recruiter-facing consistency and security configuration. + Path('tests/presentation-consistency.test.mjs').write_text('''import test from "node:test";\nimport assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\n\nasync function source(path) {\n return readFile(new URL(`../${path}`, import.meta.url), "utf8");\n}\n\ntest("landing sample mirrors the bundled deterministic fixture", async () => {\n const page = await source("app/page.tsx");\n assert.match(page, /Leakage risk" value=\\{75\\}/);\n assert.match(page, /Metric quality" value=\\{76\\}/);\n assert.match(page, /Reproducibility" value=\\{90\\}/);\n assert.match(page, /Portfolio readiness" value=\\{79\\}/);\n assert.match(page, /test-set selection risk/);\n assert.match(page, /test-set threshold tuning risk/);\n});\n\ntest("demo and upload describe the current PDF and examiner behavior", async () => {\n const demo = await source("app/demo/page.tsx");\n const upload = await source("app/upload/page.tsx");\n assert.doesNotMatch(demo, /A replacement will only be published/);\n assert.match(demo, /PDF reports are generated on demand/);\n assert.match(upload, /deterministic fallback examiner/);\n assert.match(upload, /external AI only/);\n});\n\ntest("site metadata and security headers stay recruiter-ready", async () => {\n const layout = await source("app/layout.tsx");\n const config = await source("next.config.ts");\n assert.match(layout, /Static ML notebook auditing with deterministic methodology checks/);\n assert.match(layout, /openGraph/);\n assert.match(layout, /summary_large_image/);\n assert.match(config, /Content-Security-Policy/);\n assert.match(config, /Strict-Transport-Security/);\n assert.match(config, /frame-ancestors 'none'/);\n});\n''', encoding='utf-8') + + # CI: exercise deterministic examiner, PDF with narrative, negative API contracts and runtime security headers. + ci = Path('.github/workflows/ci.yml') + text = ci.read_text(encoding='utf-8') + old = """ with open('/tmp/modelhawk-pdf-payload.json', 'w', encoding='utf-8') as handle:\n json.dump({'auditResult': audit}, handle)\n PY\n\n curl --fail --silent --show-error \\\n""" + new = """ PY\n\n curl --fail --silent --show-error \\\n -H 'Content-Type: application/json' \\\n --data-binary @/tmp/modelhawk-audit.json \\\n http://127.0.0.1:3000/api/audit/ai \\\n -o /tmp/modelhawk-ai.json\n\n python - <<'PY'\n import json\n\n with open('/tmp/modelhawk-audit.json', encoding='utf-8') as handle:\n audit = json.load(handle)\n with open('/tmp/modelhawk-ai.json', encoding='utf-8') as handle:\n examiner = json.load(handle)\n\n if examiner.get('provider') != 'deterministic':\n raise SystemExit(f\"Expected deterministic CI examiner, got {examiner.get('provider')}\")\n\n narrative = examiner.get('aiNarrative')\n required = {\n 'executiveSummary', 'technicalAssessment', 'riskInterpretation',\n 'portfolioVerdict', 'improvedRecommendations', 'suggestedMarkdownCells',\n 'vivaAnswers', 'limitations',\n }\n if not isinstance(narrative, dict) or not required.issubset(narrative):\n raise SystemExit('Examiner endpoint returned an incomplete controlled narrative')\n\n if len(narrative.get('vivaAnswers', [])) != len(audit.get('vivaQuestions', [])):\n raise SystemExit('Examiner did not preserve one answer per deterministic viva question')\n\n with open('/tmp/modelhawk-pdf-payload.json', 'w', encoding='utf-8') as handle:\n json.dump({'auditResult': audit, 'aiNarrative': narrative}, handle)\n PY\n\n curl --fail --silent --show-error \\\n""" + if text.count(old) != 1: + raise SystemExit('CI examiner insertion point changed unexpectedly') + text = text.replace(old, new) + + old = """ print(f'PDF smoke test passed: {len(report)} bytes')\n PY\n\n - name: Show container logs on failure\n""" + new = """ print(f'PDF smoke test passed: {len(report)} bytes')\n PY\n\n printf 'not a notebook' >/tmp/modelhawk-invalid.txt\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-audit.json -w '%{http_code}' \\\n -F \"file=@/tmp/modelhawk-invalid.txt;filename=invalid.txt\" \\\n http://127.0.0.1:3000/api/audit)\n test \"$status\" = \"400\"\n grep -q 'Please upload a .ipynb notebook file.' /tmp/modelhawk-invalid-audit.json\n\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-ai.json -w '%{http_code}' \\\n -H 'Content-Type: application/json' \\\n --data '{\"fileName\":\"invalid.ipynb\",\"scores\":{\"leakageRisk\":999,\"metricQuality\":1,\"reproducibility\":1,\"portfolioReadiness\":1}}' \\\n http://127.0.0.1:3000/api/audit/ai)\n test \"$status\" = \"400\"\n\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-pdf.json -w '%{http_code}' \\\n -H 'Content-Type: application/json' \\\n --data '{\"fileName\":\"invalid.ipynb\"}' \\\n http://127.0.0.1:3000/api/report/pdf)\n test \"$status\" = \"400\"\n\n - name: Verify runtime security headers\n shell: bash\n run: |\n set -euo pipefail\n curl --silent --show-error --head http://127.0.0.1:3000/ >/tmp/modelhawk-headers.txt\n grep -qi '^content-security-policy:' /tmp/modelhawk-headers.txt\n grep -qi '^strict-transport-security:' /tmp/modelhawk-headers.txt\n grep -qi '^x-content-type-options: nosniff' /tmp/modelhawk-headers.txt\n grep -qi '^x-frame-options: DENY' /tmp/modelhawk-headers.txt\n\n - name: Show container logs on failure\n""" + if text.count(old) != 1: + raise SystemExit('CI negative-contract insertion point changed unexpectedly') + ci.write_text(text.replace(old, new), encoding='utf-8') + PY + + - name: Remove one-off workflow and commit final files + shell: bash + run: | + rm .github/workflows/zz-final-polish.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add app/page.tsx app/demo/page.tsx app/upload/page.tsx app/layout.tsx next.config.ts README.md tests/presentation-consistency.test.mjs .github/workflows/ci.yml .github/workflows/zz-final-polish.yml + git commit -m "Finish ModelHawk recruiter and reliability polish" + git push origin HEAD:polish/final-consistency-20261023 From ac8aa25120907edec8250249082a9525c5a3a6b7 Mon Sep 17 00:00:00 2001 From: Alberto Reyes Castro Date: Fri, 23 Oct 2026 15:05:23 -0400 Subject: [PATCH 2/9] Retry one-off polish without workflow self-modification --- .github/workflows/zz-final-polish.yml | 85 +++++---------------------- 1 file changed, 15 insertions(+), 70 deletions(-) diff --git a/.github/workflows/zz-final-polish.yml b/.github/workflows/zz-final-polish.yml index b90b885..1552916 100644 --- a/.github/workflows/zz-final-polish.yml +++ b/.github/workflows/zz-final-polish.yml @@ -17,7 +17,7 @@ jobs: with: ref: polish/final-consistency-20261023 - - name: Apply final recruiter and reliability polish + - name: Apply recruiter-facing polish shell: bash run: | python - <<'PY' @@ -31,98 +31,43 @@ jobs: raise SystemExit(f'{path}: expected {expected} occurrence(s), found {count}: {old!r}') p.write_text(text.replace(old, new), encoding='utf-8') - # Landing: align the visual sample and examiner wording with the live deterministic fixture. replace_exact('app/page.tsx', 'title: "AI Defense Report",', 'title: "Controlled Examiner Report",') replace_exact('app/page.tsx', 'label: "AI layer",', 'label: "Examiner layer",') - replace_exact( - 'app/page.tsx', - ' "Use AI to explain the findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",', - ' "Use a controlled examiner layer — AI-assisted when configured — to explain findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",' - ) - replace_exact( - 'app/page.tsx', - ' checks, scores the risks and then uses AI to explain the findings\n clearly.', - ' checks, scores the risks and then uses a controlled examiner layer\n to explain the findings, with external AI only when configured.' - ) + replace_exact('app/page.tsx', ' "Use AI to explain the findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",', ' "Use a controlled examiner layer — AI-assisted when configured — to explain findings, generate viva questions and turn the audit into clear portfolio-ready feedback.",') + replace_exact('app/page.tsx', ' checks, scores the risks and then uses AI to explain the findings\n clearly.', ' checks, scores the risks and then uses a controlled examiner layer\n to explain the findings, with external AI only when configured.') replace_exact('app/page.tsx', 'Medium-high risk', 'High leakage risk') replace_exact('app/page.tsx', '', '') replace_exact('app/page.tsx', '', '') replace_exact('app/page.tsx', '', '') replace_exact('app/page.tsx', '', '') - replace_exact( - 'app/page.tsx', - ' Accuracy is reported as a main metric, but the dataset appears\n imbalanced. Add recall, precision, F1, PR-AUC and threshold\n justification.', - ' Multiple candidate model families appear to be evaluated on\n held-out test data before final selection. Keep the test set\n reserved for one final unbiased evaluation.' - ) + replace_exact('app/page.tsx', ' Accuracy is reported as a main metric, but the dataset appears\n imbalanced. Add recall, precision, F1, PR-AUC and threshold\n justification.', ' Multiple candidate model families appear to be evaluated on\n held-out test data before final selection. Keep the test set\n reserved for one final unbiased evaluation.') replace_exact('app/page.tsx', ' uses accuracy', ' test-set selection risk') replace_exact('app/page.tsx', ' no threshold tuning', ' test-set threshold tuning risk') replace_exact('app/page.tsx', ' imbalanced problem', ' preprocessing outside CV risk') replace_exact('app/page.tsx', ' weak reproducibility', ' custom composite score') - # Demo: remove the stale promise about a future PDF replacement. - replace_exact( - 'app/demo/page.tsx', - ' The previous sample PDF has been removed from this page because it\n was generated before the hardened audit rules. A replacement will\n only be published after it is regenerated from the current engine.', - ' PDF reports are generated on demand from the current deterministic\n audit result, so exported reports stay aligned with the hardened\n rules used by the live application.' - ) + replace_exact('app/demo/page.tsx', ' The previous sample PDF has been removed from this page because it\n was generated before the hardened audit rules. A replacement will\n only be published after it is regenerated from the current engine.', ' PDF reports are generated on demand from the current deterministic\n audit result, so exported reports stay aligned with the hardened\n rules used by the live application.') - # Upload: accurately describe deterministic fallback when no provider is configured. replace_exact('app/upload/page.tsx', 'Static audit + AI examiner', 'Static audit + controlled examiner') - replace_exact( - 'app/upload/page.tsx', - ' technical audit checks and then uses an AI examiner layer to\n generate richer project-specific explanations.', - ' technical audit checks and then uses a controlled examiner layer to\n generate richer project-specific explanations, with external AI only\n when a provider is configured.' - ) - replace_exact( - 'app/upload/page.tsx', - '', - '' - ) - replace_exact( - 'app/upload/page.tsx', - ' technical signals, generate risk scores and add AI examiner\n notes.', - ' technical signals, generate risk scores and add controlled\n examiner notes.' - ) + replace_exact('app/upload/page.tsx', ' technical audit checks and then uses an AI examiner layer to\n generate richer project-specific explanations.', ' technical audit checks and then uses a controlled examiner layer to\n generate richer project-specific explanations, with external AI only\n when a provider is configured.') + replace_exact('app/upload/page.tsx', '', '') + replace_exact('app/upload/page.tsx', ' technical signals, generate risk scores and add AI examiner\n notes.', ' technical signals, generate risk scores and add controlled\n examiner notes.') replace_exact('app/upload/page.tsx', '"Generating AI examiner notes..."', '"Generating examiner notes..."') - # SEO/social metadata: align the live site with the recruiter-facing repository description. - Path('app/layout.tsx').write_text('''import type { Metadata } from "next";\nimport "./globals.css";\n\nconst description =\n "Static ML notebook auditing with deterministic methodology checks, AI-assisted review and production PDF reports.";\n\nexport const metadata: Metadata = {\n metadataBase: new URL("https://modelhawk.onrender.com"),\n title: {\n default: "ModelHawk | Static ML Notebook Auditor",\n template: "%s | ModelHawk",\n },\n description,\n applicationName: "ModelHawk",\n keywords: [\n "machine learning",\n "ML audit",\n "Jupyter notebook",\n "model evaluation",\n "data leakage",\n "reproducibility",\n ],\n authors: [{ name: "Alberto Reyes Castro" }],\n creator: "Alberto Reyes Castro",\n openGraph: {\n type: "website",\n url: "/",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n siteName: "ModelHawk",\n images: [\n {\n url: "/docs/screenshots/01-landing-page.png",\n width: 1440,\n height: 1000,\n alt: "ModelHawk static ML notebook auditor",\n },\n ],\n },\n twitter: {\n card: "summary_large_image",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n images: ["/docs/screenshots/01-landing-page.png"],\n },\n icons: {\n icon: "/modelhawk-favicon.png",\n },\n};\n\nexport default function RootLayout({\n children,\n}: Readonly<{\n children: React.ReactNode;\n}>) {\n return (\n \n {children}\n \n );\n}\n''', encoding='utf-8') + Path('app/layout.tsx').write_text('''import type { Metadata } from "next";\nimport "./globals.css";\n\nconst description =\n "Static ML notebook auditing with deterministic methodology checks, AI-assisted review and production PDF reports.";\n\nexport const metadata: Metadata = {\n metadataBase: new URL("https://modelhawk.onrender.com"),\n title: {\n default: "ModelHawk | Static ML Notebook Auditor",\n template: "%s | ModelHawk",\n },\n description,\n applicationName: "ModelHawk",\n keywords: [\n "machine learning",\n "ML audit",\n "Jupyter notebook",\n "model evaluation",\n "data leakage",\n "reproducibility",\n ],\n authors: [{ name: "Alberto Reyes Castro" }],\n creator: "Alberto Reyes Castro",\n openGraph: {\n type: "website",\n url: "/",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n siteName: "ModelHawk",\n images: [{\n url: "/docs/screenshots/01-landing-page.png",\n width: 1440,\n height: 1000,\n alt: "ModelHawk static ML notebook auditor",\n }],\n },\n twitter: {\n card: "summary_large_image",\n title: "ModelHawk | Static ML Notebook Auditor",\n description,\n images: ["/docs/screenshots/01-landing-page.png"],\n },\n icons: { icon: "/modelhawk-favicon.png" },\n};\n\nexport default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {\n return (\n \n {children}\n \n );\n}\n''', encoding='utf-8') - # Low-risk production security headers. CSP remains compatible with Next's inline bootstrap/styles. - Path('next.config.ts').write_text('''import type { NextConfig } from "next";\n\nconst CONTENT_SECURITY_POLICY = [\n "default-src 'self'",\n "base-uri 'self'",\n "form-action 'self'",\n "frame-ancestors 'none'",\n "object-src 'none'",\n "img-src 'self' data: blob:",\n "font-src 'self' data:",\n "style-src 'self' 'unsafe-inline'",\n "script-src 'self' 'unsafe-inline'",\n "connect-src 'self'",\n "worker-src 'self' blob:",\n "manifest-src 'self'",\n "upgrade-insecure-requests",\n].join("; ");\n\nconst SECURITY_HEADERS = [\n { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY },\n { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },\n { key: "X-Content-Type-Options", value: "nosniff" },\n { key: "X-Frame-Options", value: "DENY" },\n { key: "X-DNS-Prefetch-Control", value: "off" },\n { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },\n { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },\n { key: "Cross-Origin-Opener-Policy", value: "same-origin" },\n { key: "Cross-Origin-Resource-Policy", value: "same-origin" },\n];\n\nconst nextConfig: NextConfig = {\n output: "standalone",\n poweredByHeader: false,\n async headers() {\n return [\n {\n source: "/(.*)",\n headers: SECURITY_HEADERS,\n },\n ];\n },\n};\n\nexport default nextConfig;\n''', encoding='utf-8') + Path('next.config.ts').write_text('''import type { NextConfig } from "next";\n\nconst CONTENT_SECURITY_POLICY = [\n "default-src 'self'",\n "base-uri 'self'",\n "form-action 'self'",\n "frame-ancestors 'none'",\n "object-src 'none'",\n "img-src 'self' data: blob:",\n "font-src 'self' data:",\n "style-src 'self' 'unsafe-inline'",\n "script-src 'self' 'unsafe-inline'",\n "connect-src 'self'",\n "worker-src 'self' blob:",\n "manifest-src 'self'",\n "upgrade-insecure-requests",\n].join("; ");\n\nconst SECURITY_HEADERS = [\n { key: "Content-Security-Policy", value: CONTENT_SECURITY_POLICY },\n { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },\n { key: "X-Content-Type-Options", value: "nosniff" },\n { key: "X-Frame-Options", value: "DENY" },\n { key: "X-DNS-Prefetch-Control", value: "off" },\n { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },\n { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },\n { key: "Cross-Origin-Opener-Policy", value: "same-origin" },\n { key: "Cross-Origin-Resource-Policy", value: "same-origin" },\n];\n\nconst nextConfig: NextConfig = {\n output: "standalone",\n poweredByHeader: false,\n async headers() {\n return [{ source: "/(.*)", headers: SECURITY_HEADERS }];\n },\n};\n\nexport default nextConfig;\n''', encoding='utf-8') - # README: accurately describe the free-tier wake-up UX. - replace_exact( - 'README.md', - "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." - ) + replace_exact('README.md', "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.") - # Add cheap regression coverage for recruiter-facing consistency and security configuration. - Path('tests/presentation-consistency.test.mjs').write_text('''import test from "node:test";\nimport assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\n\nasync function source(path) {\n return readFile(new URL(`../${path}`, import.meta.url), "utf8");\n}\n\ntest("landing sample mirrors the bundled deterministic fixture", async () => {\n const page = await source("app/page.tsx");\n assert.match(page, /Leakage risk" value=\\{75\\}/);\n assert.match(page, /Metric quality" value=\\{76\\}/);\n assert.match(page, /Reproducibility" value=\\{90\\}/);\n assert.match(page, /Portfolio readiness" value=\\{79\\}/);\n assert.match(page, /test-set selection risk/);\n assert.match(page, /test-set threshold tuning risk/);\n});\n\ntest("demo and upload describe the current PDF and examiner behavior", async () => {\n const demo = await source("app/demo/page.tsx");\n const upload = await source("app/upload/page.tsx");\n assert.doesNotMatch(demo, /A replacement will only be published/);\n assert.match(demo, /PDF reports are generated on demand/);\n assert.match(upload, /deterministic fallback examiner/);\n assert.match(upload, /external AI only/);\n});\n\ntest("site metadata and security headers stay recruiter-ready", async () => {\n const layout = await source("app/layout.tsx");\n const config = await source("next.config.ts");\n assert.match(layout, /Static ML notebook auditing with deterministic methodology checks/);\n assert.match(layout, /openGraph/);\n assert.match(layout, /summary_large_image/);\n assert.match(config, /Content-Security-Policy/);\n assert.match(config, /Strict-Transport-Security/);\n assert.match(config, /frame-ancestors 'none'/);\n});\n''', encoding='utf-8') - - # CI: exercise deterministic examiner, PDF with narrative, negative API contracts and runtime security headers. - ci = Path('.github/workflows/ci.yml') - text = ci.read_text(encoding='utf-8') - old = """ with open('/tmp/modelhawk-pdf-payload.json', 'w', encoding='utf-8') as handle:\n json.dump({'auditResult': audit}, handle)\n PY\n\n curl --fail --silent --show-error \\\n""" - new = """ PY\n\n curl --fail --silent --show-error \\\n -H 'Content-Type: application/json' \\\n --data-binary @/tmp/modelhawk-audit.json \\\n http://127.0.0.1:3000/api/audit/ai \\\n -o /tmp/modelhawk-ai.json\n\n python - <<'PY'\n import json\n\n with open('/tmp/modelhawk-audit.json', encoding='utf-8') as handle:\n audit = json.load(handle)\n with open('/tmp/modelhawk-ai.json', encoding='utf-8') as handle:\n examiner = json.load(handle)\n\n if examiner.get('provider') != 'deterministic':\n raise SystemExit(f\"Expected deterministic CI examiner, got {examiner.get('provider')}\")\n\n narrative = examiner.get('aiNarrative')\n required = {\n 'executiveSummary', 'technicalAssessment', 'riskInterpretation',\n 'portfolioVerdict', 'improvedRecommendations', 'suggestedMarkdownCells',\n 'vivaAnswers', 'limitations',\n }\n if not isinstance(narrative, dict) or not required.issubset(narrative):\n raise SystemExit('Examiner endpoint returned an incomplete controlled narrative')\n\n if len(narrative.get('vivaAnswers', [])) != len(audit.get('vivaQuestions', [])):\n raise SystemExit('Examiner did not preserve one answer per deterministic viva question')\n\n with open('/tmp/modelhawk-pdf-payload.json', 'w', encoding='utf-8') as handle:\n json.dump({'auditResult': audit, 'aiNarrative': narrative}, handle)\n PY\n\n curl --fail --silent --show-error \\\n""" - if text.count(old) != 1: - raise SystemExit('CI examiner insertion point changed unexpectedly') - text = text.replace(old, new) - - old = """ print(f'PDF smoke test passed: {len(report)} bytes')\n PY\n\n - name: Show container logs on failure\n""" - new = """ print(f'PDF smoke test passed: {len(report)} bytes')\n PY\n\n printf 'not a notebook' >/tmp/modelhawk-invalid.txt\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-audit.json -w '%{http_code}' \\\n -F \"file=@/tmp/modelhawk-invalid.txt;filename=invalid.txt\" \\\n http://127.0.0.1:3000/api/audit)\n test \"$status\" = \"400\"\n grep -q 'Please upload a .ipynb notebook file.' /tmp/modelhawk-invalid-audit.json\n\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-ai.json -w '%{http_code}' \\\n -H 'Content-Type: application/json' \\\n --data '{\"fileName\":\"invalid.ipynb\",\"scores\":{\"leakageRisk\":999,\"metricQuality\":1,\"reproducibility\":1,\"portfolioReadiness\":1}}' \\\n http://127.0.0.1:3000/api/audit/ai)\n test \"$status\" = \"400\"\n\n status=$(curl --silent --show-error -o /tmp/modelhawk-invalid-pdf.json -w '%{http_code}' \\\n -H 'Content-Type: application/json' \\\n --data '{\"fileName\":\"invalid.ipynb\"}' \\\n http://127.0.0.1:3000/api/report/pdf)\n test \"$status\" = \"400\"\n\n - name: Verify runtime security headers\n shell: bash\n run: |\n set -euo pipefail\n curl --silent --show-error --head http://127.0.0.1:3000/ >/tmp/modelhawk-headers.txt\n grep -qi '^content-security-policy:' /tmp/modelhawk-headers.txt\n grep -qi '^strict-transport-security:' /tmp/modelhawk-headers.txt\n grep -qi '^x-content-type-options: nosniff' /tmp/modelhawk-headers.txt\n grep -qi '^x-frame-options: DENY' /tmp/modelhawk-headers.txt\n\n - name: Show container logs on failure\n""" - if text.count(old) != 1: - raise SystemExit('CI negative-contract insertion point changed unexpectedly') - ci.write_text(text.replace(old, new), encoding='utf-8') + Path('tests/presentation-consistency.test.mjs').write_text('''import test from "node:test";\nimport assert from "node:assert/strict";\nimport { readFile } from "node:fs/promises";\n\nasync function source(path) {\n return readFile(new URL(`../${path}`, import.meta.url), "utf8");\n}\n\ntest("landing sample mirrors the bundled deterministic fixture", async () => {\n const page = await source("app/page.tsx");\n assert.match(page, /Leakage risk" value=\\{75\\}/);\n assert.match(page, /Metric quality" value=\\{76\\}/);\n assert.match(page, /Reproducibility" value=\\{90\\}/);\n assert.match(page, /Portfolio readiness" value=\\{79\\}/);\n assert.match(page, /test-set selection risk/);\n assert.match(page, /test-set threshold tuning risk/);\n});\n\ntest("demo and upload describe current PDF and examiner behavior", async () => {\n const demo = await source("app/demo/page.tsx");\n const upload = await source("app/upload/page.tsx");\n assert.doesNotMatch(demo, /A replacement will only be published/);\n assert.match(demo, /PDF reports are generated on demand/);\n assert.match(upload, /deterministic fallback examiner/);\n assert.match(upload, /external AI only/);\n});\n\ntest("metadata and security configuration stay recruiter-ready", async () => {\n const layout = await source("app/layout.tsx");\n const config = await source("next.config.ts");\n assert.match(layout, /Static ML notebook auditing with deterministic methodology checks/);\n assert.match(layout, /openGraph/);\n assert.match(layout, /summary_large_image/);\n assert.match(config, /Content-Security-Policy/);\n assert.match(config, /Strict-Transport-Security/);\n assert.match(config, /frame-ancestors 'none'/);\n});\n''', encoding='utf-8') PY - - name: Remove one-off workflow and commit final files + - name: Commit non-workflow polish shell: bash run: | - rm .github/workflows/zz-final-polish.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add app/page.tsx app/demo/page.tsx app/upload/page.tsx app/layout.tsx next.config.ts README.md tests/presentation-consistency.test.mjs .github/workflows/ci.yml .github/workflows/zz-final-polish.yml - git commit -m "Finish ModelHawk recruiter and reliability polish" + git add app/page.tsx app/demo/page.tsx app/upload/page.tsx app/layout.tsx next.config.ts README.md tests/presentation-consistency.test.mjs + git commit -m "Finish recruiter-facing consistency and security polish" git push origin HEAD:polish/final-consistency-20261023 From 3a431bc06f1ca6124f4efeaa3210299e53814fc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:05:29 +0000 Subject: [PATCH 3/9] Finish recruiter-facing consistency and security polish --- README.md | 2 +- app/demo/page.tsx | 6 +-- app/layout.tsx | 52 +++++++++++++++++++------ app/page.tsx | 34 ++++++++-------- app/upload/page.tsx | 15 +++---- next.config.ts | 27 ++++++++++--- tests/presentation-consistency.test.mjs | 37 ++++++++++++++++++ 7 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 tests/presentation-consistency.test.mjs diff --git a/README.md b/README.md index 3824fba..1ccb441 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/demo/page.tsx b/app/demo/page.tsx index 92e9cf9..82a4a82 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -166,9 +166,9 @@ export default function DemoPage() {

- 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.

diff --git a/app/layout.tsx b/app/layout.tsx index 98648d5..a73b0be 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( {children} ); -} \ No newline at end of file +} diff --git a/app/page.tsx b/app/page.tsx index 7eb3ab8..1ca3096 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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", }, ]; @@ -162,8 +162,8 @@ export default function Home() {

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.

@@ -206,15 +206,15 @@ export default function Home() {
- Medium-high risk + High leakage risk
- - - - + + + +
@@ -222,9 +222,9 @@ export default function Home() { Hawk finding

- 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.

@@ -244,16 +244,16 @@ export default function Home() {

- uses accuracy + test-set selection risk - no threshold tuning + test-set threshold tuning risk - imbalanced problem + preprocessing outside CV risk - weak reproducibility + custom composite score
diff --git a/app/upload/page.tsx b/app/upload/page.tsx index b1ab3f6..6e98df1 100644 --- a/app/upload/page.tsx +++ b/app/upload/page.tsx @@ -231,7 +231,7 @@ export default function UploadPage() {
- Static audit + AI examiner + Static audit + controlled examiner

@@ -243,14 +243,15 @@ export default function UploadPage() {

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.

- +

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

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.