#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const DEFAULT_REQUIRED_MARKERS = [ '空间名称', '关键尺寸', '门窗洞口', '梁柱/墙体限制', '水电/管道/烟道', '安装避让备注' ]; const SAMPLE_ANALYSIS = { project: '实战二 P0 演示样本', image: 'sample-measurement-plan.jpg', sourceType: 'image', detectedMarkers: [ { area: '厨房右侧墙面', markerType: '关键尺寸', value: '3200mm', evidence: '墙面横向尺寸已标注', confidence: 0.9 }, { area: '厨房水槽区域', markerType: '水电/管道/烟道', value: '下水管', evidence: '右下角有管道符号和文字', confidence: 0.82 } ], missingMarkers: [ { area: '厨房左侧门洞', missingType: '门洞宽度/高度', risk: '门洞尺寸缺失,后续柜体和动线复核容易出现偏差', evidence: '图中能看到门洞轮廓,但附近没有宽高数值', confidence: 0.86 }, { area: '窗户下沿', missingType: '窗台高度', risk: '窗台高度缺失,可能影响台面、吊柜或窗帘盒设计', evidence: '窗户位置已画出,但没有离地高度标注', confidence: 0.78 } ], uncertainAreas: [ { area: '厨房右上角', reason: '疑似烟道或立管,但图中文字不清晰', action: '请现场照片或原始量尺单复核', confidence: 0.58 } ], overallJudgement: '存在关键缺标记,建议人工复核后再进入标准尺寸表。' }; function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i++) { const token = argv[i]; if (!token.startsWith('--')) continue; const eq = token.indexOf('='); if (eq >= 0) { args[token.slice(2, eq)] = token.slice(eq + 1); continue; } const key = token.slice(2); const next = argv[i + 1]; if (next && !next.startsWith('--')) { args[key] = next; i++; } else { args[key] = true; } } return args; } function splitList(value) { if (!value) return []; if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean); return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean); } function readJsonFile(filePath) { if (!filePath) return {}; const absolute = path.resolve(filePath); if (!fs.existsSync(absolute)) { throw new Error(`Input JSON not found: ${absolute}`); } return JSON.parse(fs.readFileSync(absolute, 'utf8')); } function parseJsonValue(value, fallback) { if (!value) return fallback; if (Array.isArray(value) || typeof value === 'object') return value; return JSON.parse(String(value)); } function asArray(value) { return Array.isArray(value) ? value : []; } function normalizeInput(input = {}) { const fromFile = readJsonFile(input.input || input['input-json']); const detectedMarkers = parseJsonValue(input.detectedMarkers || input['detected-markers'], fromFile.detectedMarkers || []); const missingMarkers = parseJsonValue(input.missingMarkers || input['missing-markers'], fromFile.missingMarkers || []); const uncertainAreas = parseJsonValue(input.uncertainAreas || input['uncertain-areas'], fromFile.uncertainAreas || []); return { ...fromFile, ...input, sample: Boolean(input.sample || fromFile.sample), project: input.project || fromFile.project, image: input.image || input.imageUrl || input.imagePath || fromFile.image || fromFile.imageUrl || fromFile.imagePath, imageUrl: input.imageUrl || input['image-url'] || fromFile.imageUrl, imagePath: input.imagePath || input['image-path'] || fromFile.imagePath, sourceType: input.sourceType || input['source-type'] || fromFile.sourceType, requiredMarkers: splitList(input.requiredMarkers || input['required-markers']).length ? splitList(input.requiredMarkers || input['required-markers']) : fromFile.requiredMarkers, detectedMarkers: asArray(detectedMarkers), missingMarkers: asArray(missingMarkers), uncertainAreas: asArray(uncertainAreas), overallJudgement: input.overallJudgement || input['overall-judgement'] || fromFile.overallJudgement }; } function okResult(payload = {}) { return { status: 'ok', assistantMessage: payload.assistantMessage || '', summary: payload.summary || {}, data: payload.data || {}, files: payload.files || [], nextActions: payload.nextActions || [], warnings: payload.warnings || [], errors: payload.errors || [] }; } function errorResult(message, payload = {}) { return { status: 'error', assistantMessage: message, summary: payload.summary || {}, data: payload.data || {}, files: payload.files || [], nextActions: payload.nextActions || [], warnings: payload.warnings || [], errors: payload.errors || [{ message }] }; } function normalizeConfidence(value) { if (typeof value !== 'number' || Number.isNaN(value)) return 0.7; return Math.max(0, Math.min(1, value)); } function normalizeMarker(item = {}) { return { area: item.area || item.location || item.space || '未指明区域', markerType: item.markerType || item.type || item.label || '未分类标记', value: item.value || item.text || '', evidence: item.evidence || item.reason || '', confidence: normalizeConfidence(item.confidence) }; } function normalizeMissing(item = {}) { return { area: item.area || item.location || item.space || '未指明区域', missingType: item.missingType || item.markerType || item.type || '未说明缺失项', risk: item.risk || item.impact || '需要人工复核,避免后续尺寸整理漏项。', evidence: item.evidence || item.reason || '', confidence: normalizeConfidence(item.confidence) }; } function normalizeUncertain(item = {}) { return { area: item.area || item.location || item.space || '未指明区域', reason: item.reason || item.evidence || '图像信息不足,暂无法确认。', action: item.action || '建议回看原始量尺图或现场照片后复核。', confidence: normalizeConfidence(item.confidence) }; } function countByType(items) { return items.reduce((acc, item) => { const key = item.markerType || item.missingType || '未分类'; acc[key] = (acc[key] || 0) + 1; return acc; }, {}); } function buildMeasurementMarkerReport(input = {}) { const source = input.sample ? SAMPLE_ANALYSIS : input; const detectedMarkers = asArray(source.detectedMarkers).map(normalizeMarker); const missingMarkers = asArray(source.missingMarkers).map(normalizeMissing); const uncertainAreas = asArray(source.uncertainAreas).map(normalizeUncertain); const requiredMarkers = asArray(source.requiredMarkers).length ? source.requiredMarkers.map(String) : DEFAULT_REQUIRED_MARKERS; const status = missingMarkers.length ? 'needs_review' : 'ok'; const pass = status === 'ok' && uncertainAreas.length === 0; const summary = { project: source.project || '装修量尺图缺标记检查', image: source.image || source.imageUrl || source.imagePath || '', sourceType: source.sourceType || 'image', pass, status, detectedCount: detectedMarkers.length, missingCount: missingMarkers.length, uncertainCount: uncertainAreas.length, detectedByType: countByType(detectedMarkers), missingByType: countByType(missingMarkers) }; const assistantMessage = renderAssistantMessage({ summary, requiredMarkers, detectedMarkers, missingMarkers, uncertainAreas, overallJudgement: source.overallJudgement }); return { summary, data: { requiredMarkers, detectedMarkers, missingMarkers, uncertainAreas, overallJudgement: source.overallJudgement || defaultJudgement(summary) }, assistantMessage }; } function defaultJudgement(summary) { if (summary.missingCount > 0) { return '当前量尺图存在疑似未标记位置,建议先人工复核缺失项,再输出标准化尺寸表。'; } if (summary.uncertainCount > 0) { return '当前未发现明确缺标,但仍有图像不清晰区域,需要人工确认。'; } return '当前未发现明显缺标记,可进入下一步标准化整理。'; } function renderAssistantMessage({ summary, requiredMarkers, detectedMarkers, missingMarkers, uncertainAreas, overallJudgement }) { const lines = []; lines.push('## 量尺图缺标记检查(P0)'); lines.push(''); lines.push(`**结论**:${overallJudgement || defaultJudgement(summary)}`); lines.push(''); lines.push(`- 已识别标记:${summary.detectedCount} 项`); lines.push(`- 疑似缺标记:${summary.missingCount} 项`); lines.push(`- 待确认区域:${summary.uncertainCount} 项`); lines.push(''); lines.push('### 本轮检查口径'); requiredMarkers.forEach(item => lines.push(`- ${item}`)); lines.push(''); lines.push('### 疑似未标记位置'); if (missingMarkers.length) { missingMarkers.forEach((item, index) => { lines.push(`${index + 1}. ${item.area}:缺少「${item.missingType}」`); lines.push(` - 风险:${item.risk}`); if (item.evidence) lines.push(` - 图像依据:${item.evidence}`); lines.push(` - 置信度:${Math.round(item.confidence * 100)}%`); }); } else { lines.push('- 暂未发现明确缺标记。'); } lines.push(''); if (uncertainAreas.length) { lines.push('### 待人工复核区域'); uncertainAreas.forEach((item, index) => { lines.push(`${index + 1}. ${item.area}:${item.reason}`); lines.push(` - 建议动作:${item.action}`); lines.push(` - 置信度:${Math.round(item.confidence * 100)}%`); }); lines.push(''); } if (detectedMarkers.length) { lines.push('### 已识别标记摘录'); detectedMarkers.slice(0, 8).forEach((item, index) => { const value = item.value ? `:${item.value}` : ''; const evidence = item.evidence ? `(${item.evidence})` : ''; lines.push(`${index + 1}. ${item.area} / ${item.markerType}${value}${evidence}`); }); if (detectedMarkers.length > 8) { lines.push(`- 另有 ${detectedMarkers.length - 8} 项已识别标记写入结构化结果。`); } lines.push(''); } lines.push('### 下一步'); if (missingMarkers.length || uncertainAreas.length) { lines.push('- 先让量尺/设计同事确认上述缺标或模糊区域。'); lines.push('- 复核后补齐尺寸、限制条件或备注,再生成标准化尺寸对照表。'); } else { lines.push('- 可继续输出标准化尺寸对照表和异常预警清单。'); } return lines.join('\n'); } function writeMeasurementMarkerReport(outputDir, report) { const absolute = path.resolve(outputDir || path.join('outputs', 'measurement-marker-check', new Date().toISOString().slice(0, 10))); fs.mkdirSync(absolute, { recursive: true }); const jsonPath = path.join(absolute, 'measurement-marker-check-result.json'); const mdPath = path.join(absolute, 'measurement-marker-check-report.md'); fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8'); fs.writeFileSync(mdPath, report.assistantMessage, 'utf8'); return [jsonPath, mdPath]; } async function runMeasurementMarkerCheck(input = {}) { const normalized = normalizeInput(input); const outputDir = path.resolve(normalized.output || path.join('outputs', 'measurement-marker-check', new Date().toISOString().slice(0, 10))); if (!normalized.sample && !normalized.image && !normalized.imageUrl && !normalized.imagePath) { return errorResult('请提供量尺图片路径/URL,或使用 --sample 跑 P0 演示样例。', { nextActions: [ '上传或传入一张量尺图', 'Claude 先基于图片输出 detectedMarkers / missingMarkers / uncertainAreas', '再调用本工具生成 P0 缺标记报告' ] }); } if (!normalized.sample && !normalized.detectedMarkers.length && !normalized.missingMarkers.length && !normalized.uncertainAreas.length) { return { status: 'needs_vision_observation', assistantMessage: [ '已收到量尺图片入口,但还缺少看图后的结构化观察。', '', '请先让 Claude 直接观察上传的量尺图,提取:', '- detectedMarkers:已标记的空间、尺寸、门窗、管道、梁柱、备注等', '- missingMarkers:疑似未标记的位置、缺失类型、风险、图像依据、置信度', '- uncertainAreas:图像模糊或无法确定的位置', '', '随后把这些结构化结果传给本工具,即可生成 P0 缺标记报告。' ].join('\n'), summary: { image: normalized.image || normalized.imageUrl || normalized.imagePath, needsVisionObservation: true }, data: { expectedInputShape: { detectedMarkers: [{ area: '厨房右侧墙面', markerType: '关键尺寸', value: '3200mm', evidence: '墙面横向尺寸已标注', confidence: 0.9 }], missingMarkers: [{ area: '窗户下沿', missingType: '窗台高度', risk: '影响台面或窗帘盒设计', evidence: '窗户有轮廓但未见离地高度', confidence: 0.78 }], uncertainAreas: [{ area: '右上角', reason: '文字模糊', action: '人工复核原图', confidence: 0.58 }] } }, files: [], nextActions: ['先进行图片视觉识别', '再调用 measurement marker check 工具生成报告'], warnings: ['P0 阶段不在本地工具内直接调用视觉模型,由 Claude Code 视觉能力或上游识图服务提供观察结果。'], errors: [] }; } const report = buildMeasurementMarkerReport(normalized); const files = writeMeasurementMarkerReport(outputDir, report); return okResult({ assistantMessage: report.assistantMessage, summary: report.summary, data: report.data, files, nextActions: report.summary.missingCount || report.summary.uncertainCount ? ['让量尺/设计同事补充缺标或模糊区域', '补齐后继续输出标准化尺寸对照表'] : ['继续输出标准化尺寸对照表', '可选:与人工标准表做差异对比'], warnings: normalized.sample ? ['当前使用 P0 sample 演示数据。'] : [] }); } async function main() { const args = parseArgs(process.argv.slice(2)); try { const result = await runMeasurementMarkerCheck(args); console.log(JSON.stringify(result, null, 2)); if (args.resultPrefix || args['result-prefix']) { const prefix = args.resultPrefix || args['result-prefix']; console.log(`${prefix}=${JSON.stringify(result)}`); } process.exit(result.status === 'ok' || result.status === 'needs_vision_observation' ? 0 : 1); } catch (error) { const result = errorResult(error.message || String(error)); console.log(JSON.stringify(result, null, 2)); process.exit(1); } } if (require.main === module) { main(); } module.exports = { runMeasurementMarkerCheck, buildMeasurementMarkerReport, SAMPLE_ANALYSIS, DEFAULT_REQUIRED_MARKERS };