Initial commit: CMM Report Analyzer
FastAPI app that parses CMM inspection reports (PDF/Excel/CSV), computes SPC metrics (Cp/Cpk/Pp/Ppk, control limits, Shapiro-Wilk), generates interactive Plotly charts, and provides AI-powered quality summaries via Azure OpenAI with graceful fallback. Includes 21 passing tests covering parsers, SPC calculations, and API endpoints.
This commit is contained in:
247
app/static/css/style.css
Normal file
247
app/static/css/style.css
Normal file
@@ -0,0 +1,247 @@
|
||||
:root {
|
||||
--bg: #f4f6f8;
|
||||
--surface: #ffffff;
|
||||
--primary: #1a5276;
|
||||
--primary-light: #2980b9;
|
||||
--accent: #2ecc71;
|
||||
--danger: #e74c3c;
|
||||
--warn: #f39c12;
|
||||
--text: #2c3e50;
|
||||
--text-muted: #7f8c8d;
|
||||
--border: #dce1e6;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Drop zone */
|
||||
#drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
#drop-zone:hover, #drop-zone.dragover {
|
||||
border-color: var(--primary-light);
|
||||
background: rgba(41, 128, 185, 0.04);
|
||||
}
|
||||
|
||||
.drop-content svg { color: var(--text-muted); margin-bottom: 0.75rem; }
|
||||
.drop-content p { color: var(--text); font-size: 1rem; }
|
||||
.drop-content .hint { color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
|
||||
/* File list */
|
||||
#file-list {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-tag {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.file-tag .remove {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.file-tag .remove:hover { opacity: 1; }
|
||||
|
||||
/* Upload button */
|
||||
#upload-btn {
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 2rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#upload-btn:hover:not(:disabled) { background: var(--primary-light); }
|
||||
#upload-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Spinner */
|
||||
#status-section {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 1rem;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Results */
|
||||
.file-result {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.file-result h2 {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.file-result .error {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SPC table */
|
||||
.spc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow-x: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.spc-table th, .spc-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: right;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spc-table th {
|
||||
background: var(--bg);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.spc-table th:first-child, .spc-table td:first-child { text-align: left; }
|
||||
|
||||
.spc-table tr:hover td { background: rgba(41, 128, 185, 0.04); }
|
||||
|
||||
.cpk-good { color: var(--accent); font-weight: 600; }
|
||||
.cpk-warn { color: var(--warn); font-weight: 600; }
|
||||
.cpk-bad { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* Summary */
|
||||
.summary {
|
||||
background: var(--bg);
|
||||
border-left: 4px solid var(--primary);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-full { grid-column: 1 / -1; }
|
||||
|
||||
/* Measurements table */
|
||||
.meas-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meas-toggle:hover { background: var(--bg); }
|
||||
|
||||
.meas-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.meas-table th, .meas-table td {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.meas-table th { background: var(--bg); font-weight: 600; }
|
||||
.meas-table th:first-child, .meas-table td:first-child { text-align: left; }
|
||||
|
||||
.meas-table .oot { background: rgba(231, 76, 60, 0.08); color: var(--danger); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
main { padding: 1rem; }
|
||||
}
|
||||
49
app/static/index.html
Normal file
49
app/static/index.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CMM Report Analyzer</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<script src="https://cdn.plot.ly/plotly-2.35.0.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>CMM Report Analyzer</h1>
|
||||
<p class="subtitle">Upload CMM inspection reports for SPC analysis and AI-powered summaries</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Upload area -->
|
||||
<section id="upload-section">
|
||||
<div id="drop-zone">
|
||||
<div class="drop-content">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<p>Drag & drop PDF or Excel files here</p>
|
||||
<p class="hint">or click to browse — .pdf, .xlsx, .xls, .csv</p>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple accept=".pdf,.xlsx,.xls,.csv" hidden>
|
||||
</div>
|
||||
<div id="file-list"></div>
|
||||
<button id="upload-btn" disabled>Analyze Files</button>
|
||||
</section>
|
||||
|
||||
<!-- Status -->
|
||||
<section id="status-section" hidden>
|
||||
<div class="spinner"></div>
|
||||
<p id="status-text">Processing files...</p>
|
||||
</section>
|
||||
|
||||
<!-- Results -->
|
||||
<section id="results-section" hidden>
|
||||
<div id="results-container"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
204
app/static/js/app.js
Normal file
204
app/static/js/app.js
Normal file
@@ -0,0 +1,204 @@
|
||||
(() => {
|
||||
const dropZone = document.getElementById("drop-zone");
|
||||
const fileInput = document.getElementById("file-input");
|
||||
const fileList = document.getElementById("file-list");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadSection = document.getElementById("upload-section");
|
||||
const statusSection = document.getElementById("status-section");
|
||||
const statusText = document.getElementById("status-text");
|
||||
const resultsSection = document.getElementById("results-section");
|
||||
const resultsContainer = document.getElementById("results-container");
|
||||
|
||||
let selectedFiles = [];
|
||||
|
||||
// Drag & drop
|
||||
dropZone.addEventListener("click", () => fileInput.click());
|
||||
dropZone.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); });
|
||||
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
|
||||
dropZone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove("dragover");
|
||||
addFiles(e.dataTransfer.files);
|
||||
});
|
||||
fileInput.addEventListener("change", () => addFiles(fileInput.files));
|
||||
|
||||
function addFiles(files) {
|
||||
for (const f of files) {
|
||||
if (!selectedFiles.some((s) => s.name === f.name && s.size === f.size)) {
|
||||
selectedFiles.push(f);
|
||||
}
|
||||
}
|
||||
renderFileList();
|
||||
}
|
||||
|
||||
function renderFileList() {
|
||||
fileList.innerHTML = "";
|
||||
selectedFiles.forEach((f, i) => {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "file-tag";
|
||||
tag.innerHTML = `${f.name} <span class="remove" data-idx="${i}">×</span>`;
|
||||
fileList.appendChild(tag);
|
||||
});
|
||||
fileList.querySelectorAll(".remove").forEach((btn) =>
|
||||
btn.addEventListener("click", (e) => {
|
||||
selectedFiles.splice(+e.target.dataset.idx, 1);
|
||||
renderFileList();
|
||||
})
|
||||
);
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
// Upload
|
||||
uploadBtn.addEventListener("click", async () => {
|
||||
if (!selectedFiles.length) return;
|
||||
|
||||
uploadSection.hidden = true;
|
||||
statusSection.hidden = false;
|
||||
resultsSection.hidden = true;
|
||||
statusText.textContent = `Uploading ${selectedFiles.length} file(s)...`;
|
||||
|
||||
const form = new FormData();
|
||||
selectedFiles.forEach((f) => form.append("files", f));
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/upload", { method: "POST", body: form });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
||||
throw new Error(err.detail || "Upload failed");
|
||||
}
|
||||
const { batch_id } = await resp.json();
|
||||
statusText.textContent = "Analyzing...";
|
||||
await pollResults(batch_id);
|
||||
} catch (err) {
|
||||
statusSection.hidden = true;
|
||||
uploadSection.hidden = false;
|
||||
alert("Error: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function pollResults(batchId) {
|
||||
const maxAttempts = 60;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const resp = await fetch(`/api/results/${batchId}`);
|
||||
if (!resp.ok) { await sleep(1000); continue; }
|
||||
const data = await resp.json();
|
||||
if (data.status === "complete") {
|
||||
renderResults(data);
|
||||
return;
|
||||
}
|
||||
statusText.textContent = `Analyzing... (${i + 1}s)`;
|
||||
await sleep(1000);
|
||||
}
|
||||
statusSection.hidden = true;
|
||||
uploadSection.hidden = false;
|
||||
alert("Timed out waiting for results");
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||||
|
||||
// Render
|
||||
function renderResults(data) {
|
||||
statusSection.hidden = true;
|
||||
resultsSection.hidden = false;
|
||||
resultsContainer.innerHTML = "";
|
||||
|
||||
for (const file of data.files) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "file-result";
|
||||
|
||||
if (file.error) {
|
||||
div.innerHTML = `<h2>${esc(file.filename)}</h2><p class="error">Error: ${esc(file.error)}</p>`;
|
||||
resultsContainer.appendChild(div);
|
||||
continue;
|
||||
}
|
||||
|
||||
let html = `<h2>${esc(file.filename)}</h2>`;
|
||||
|
||||
// AI Summary
|
||||
html += `<div class="summary">${esc(file.summary)}</div>`;
|
||||
|
||||
// SPC table
|
||||
if (file.spc.length) {
|
||||
html += `<table class="spc-table"><thead><tr>
|
||||
<th>Feature</th><th>n</th><th>Mean</th><th>Std</th>
|
||||
<th>Cp</th><th>Cpk</th><th>Pp</th><th>Ppk</th>
|
||||
<th>USL</th><th>LSL</th><th>OOS</th>
|
||||
</tr></thead><tbody>`;
|
||||
for (const s of file.spc) {
|
||||
const cpkClass = s.cpk === null ? "" : s.cpk >= 1.33 ? "cpk-good" : s.cpk >= 1.0 ? "cpk-warn" : "cpk-bad";
|
||||
html += `<tr>
|
||||
<td>${esc(s.feature_name)}</td><td>${s.n}</td>
|
||||
<td>${s.mean}</td><td>${s.std}</td>
|
||||
<td>${fmtIdx(s.cp)}</td><td class="${cpkClass}">${fmtIdx(s.cpk)}</td>
|
||||
<td>${fmtIdx(s.pp)}</td><td>${fmtIdx(s.ppk)}</td>
|
||||
<td>${s.usl}</td><td>${s.lsl}</td><td>${s.out_of_spec_count}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
|
||||
// Charts
|
||||
html += `<div class="charts-grid">`;
|
||||
const histDivs = (file.charts.histograms || []).map((_, i) => `hist-${data.batch_id}-${file.filename}-${i}`);
|
||||
const ctrlDivs = (file.charts.control_charts || []).map((_, i) => `ctrl-${data.batch_id}-${file.filename}-${i}`);
|
||||
const capDiv = file.charts.capability_bar ? `cap-${data.batch_id}-${file.filename}` : null;
|
||||
|
||||
histDivs.forEach((id) => { html += `<div class="chart-container" id="${id}"></div>`; });
|
||||
ctrlDivs.forEach((id) => { html += `<div class="chart-container" id="${id}"></div>`; });
|
||||
if (capDiv) html += `<div class="chart-container chart-full" id="${capDiv}"></div>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Measurements toggle
|
||||
if (file.report.measurements && file.report.measurements.length) {
|
||||
const tableId = `meas-${data.batch_id}-${file.filename}`;
|
||||
html += `<button class="meas-toggle" onclick="document.getElementById('${tableId}').hidden = !document.getElementById('${tableId}').hidden">
|
||||
Show/Hide Measurements (${file.report.measurements.length})
|
||||
</button>`;
|
||||
html += `<div id="${tableId}" hidden><table class="meas-table"><thead><tr>
|
||||
<th>Feature</th><th>Nominal</th><th>Actual</th><th>Dev</th>
|
||||
<th>Tol+</th><th>Tol-</th><th>USL</th><th>LSL</th><th>Status</th>
|
||||
</tr></thead><tbody>`;
|
||||
for (const m of file.report.measurements) {
|
||||
const cls = m.in_tolerance ? "" : "oot";
|
||||
html += `<tr class="${cls}">
|
||||
<td>${esc(m.feature_name)}</td><td>${m.nominal}</td><td>${m.actual}</td>
|
||||
<td>${m.deviation.toFixed(4)}</td><td>+${m.tolerance_plus}</td><td>${m.tolerance_minus}</td>
|
||||
<td>${m.usl}</td><td>${m.lsl}</td>
|
||||
<td>${m.in_tolerance ? "OK" : "OOT"}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</tbody></table></div>`;
|
||||
}
|
||||
|
||||
div.innerHTML = html;
|
||||
resultsContainer.appendChild(div);
|
||||
|
||||
// Render Plotly charts after DOM insertion
|
||||
requestAnimationFrame(() => {
|
||||
(file.charts.histograms || []).forEach((chart, i) => {
|
||||
Plotly.newPlot(histDivs[i], chart.data, { ...chart.layout, autosize: true }, { responsive: true });
|
||||
});
|
||||
(file.charts.control_charts || []).forEach((chart, i) => {
|
||||
Plotly.newPlot(ctrlDivs[i], chart.data, { ...chart.layout, autosize: true }, { responsive: true });
|
||||
});
|
||||
if (capDiv && file.charts.capability_bar) {
|
||||
const cap = file.charts.capability_bar;
|
||||
Plotly.newPlot(capDiv, cap.data, { ...cap.layout, autosize: true }, { responsive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reset for new uploads
|
||||
selectedFiles = [];
|
||||
renderFileList();
|
||||
uploadSection.hidden = false;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtIdx(v) { return v === null || v === undefined ? "N/A" : v.toFixed(3); }
|
||||
})();
|
||||
Reference in New Issue
Block a user