measurement-marker-check-run.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const DEFAULT_REQUIRED_MARKERS = [
  5. '空间名称',
  6. '关键尺寸',
  7. '门窗洞口',
  8. '梁柱/墙体限制',
  9. '水电/管道/烟道',
  10. '安装避让备注'
  11. ];
  12. const SAMPLE_ANALYSIS = {
  13. project: '实战二 P0 演示样本',
  14. image: 'sample-measurement-plan.jpg',
  15. sourceType: 'image',
  16. detectedMarkers: [
  17. {
  18. area: '厨房右侧墙面',
  19. markerType: '关键尺寸',
  20. value: '3200mm',
  21. evidence: '墙面横向尺寸已标注',
  22. confidence: 0.9
  23. },
  24. {
  25. area: '厨房水槽区域',
  26. markerType: '水电/管道/烟道',
  27. value: '下水管',
  28. evidence: '右下角有管道符号和文字',
  29. confidence: 0.82
  30. }
  31. ],
  32. missingMarkers: [
  33. {
  34. area: '厨房左侧门洞',
  35. missingType: '门洞宽度/高度',
  36. risk: '门洞尺寸缺失,后续柜体和动线复核容易出现偏差',
  37. evidence: '图中能看到门洞轮廓,但附近没有宽高数值',
  38. confidence: 0.86
  39. },
  40. {
  41. area: '窗户下沿',
  42. missingType: '窗台高度',
  43. risk: '窗台高度缺失,可能影响台面、吊柜或窗帘盒设计',
  44. evidence: '窗户位置已画出,但没有离地高度标注',
  45. confidence: 0.78
  46. }
  47. ],
  48. uncertainAreas: [
  49. {
  50. area: '厨房右上角',
  51. reason: '疑似烟道或立管,但图中文字不清晰',
  52. action: '请现场照片或原始量尺单复核',
  53. confidence: 0.58
  54. }
  55. ],
  56. overallJudgement: '存在关键缺标记,建议人工复核后再进入标准尺寸表。'
  57. };
  58. function parseArgs(argv) {
  59. const args = {};
  60. for (let i = 0; i < argv.length; i++) {
  61. const token = argv[i];
  62. if (!token.startsWith('--')) continue;
  63. const eq = token.indexOf('=');
  64. if (eq >= 0) {
  65. args[token.slice(2, eq)] = token.slice(eq + 1);
  66. continue;
  67. }
  68. const key = token.slice(2);
  69. const next = argv[i + 1];
  70. if (next && !next.startsWith('--')) {
  71. args[key] = next;
  72. i++;
  73. } else {
  74. args[key] = true;
  75. }
  76. }
  77. return args;
  78. }
  79. function splitList(value) {
  80. if (!value) return [];
  81. if (Array.isArray(value)) return value.map(String).map(item => item.trim()).filter(Boolean);
  82. return String(value).split(/[,,、\n\r]+/).map(item => item.trim()).filter(Boolean);
  83. }
  84. function readJsonFile(filePath) {
  85. if (!filePath) return {};
  86. const absolute = path.resolve(filePath);
  87. if (!fs.existsSync(absolute)) {
  88. throw new Error(`Input JSON not found: ${absolute}`);
  89. }
  90. return JSON.parse(fs.readFileSync(absolute, 'utf8'));
  91. }
  92. function parseJsonValue(value, fallback) {
  93. if (!value) return fallback;
  94. if (Array.isArray(value) || typeof value === 'object') return value;
  95. return JSON.parse(String(value));
  96. }
  97. function asArray(value) {
  98. return Array.isArray(value) ? value : [];
  99. }
  100. function normalizeInput(input = {}) {
  101. const fromFile = readJsonFile(input.input || input['input-json']);
  102. const detectedMarkers = parseJsonValue(input.detectedMarkers || input['detected-markers'], fromFile.detectedMarkers || []);
  103. const missingMarkers = parseJsonValue(input.missingMarkers || input['missing-markers'], fromFile.missingMarkers || []);
  104. const uncertainAreas = parseJsonValue(input.uncertainAreas || input['uncertain-areas'], fromFile.uncertainAreas || []);
  105. return {
  106. ...fromFile,
  107. ...input,
  108. sample: Boolean(input.sample || fromFile.sample),
  109. project: input.project || fromFile.project,
  110. image: input.image || input.imageUrl || input.imagePath || fromFile.image || fromFile.imageUrl || fromFile.imagePath,
  111. imageUrl: input.imageUrl || input['image-url'] || fromFile.imageUrl,
  112. imagePath: input.imagePath || input['image-path'] || fromFile.imagePath,
  113. sourceType: input.sourceType || input['source-type'] || fromFile.sourceType,
  114. requiredMarkers: splitList(input.requiredMarkers || input['required-markers']).length
  115. ? splitList(input.requiredMarkers || input['required-markers'])
  116. : fromFile.requiredMarkers,
  117. detectedMarkers: asArray(detectedMarkers),
  118. missingMarkers: asArray(missingMarkers),
  119. uncertainAreas: asArray(uncertainAreas),
  120. overallJudgement: input.overallJudgement || input['overall-judgement'] || fromFile.overallJudgement
  121. };
  122. }
  123. function okResult(payload = {}) {
  124. return {
  125. status: 'ok',
  126. assistantMessage: payload.assistantMessage || '',
  127. summary: payload.summary || {},
  128. data: payload.data || {},
  129. files: payload.files || [],
  130. nextActions: payload.nextActions || [],
  131. warnings: payload.warnings || [],
  132. errors: payload.errors || []
  133. };
  134. }
  135. function errorResult(message, payload = {}) {
  136. return {
  137. status: 'error',
  138. assistantMessage: message,
  139. summary: payload.summary || {},
  140. data: payload.data || {},
  141. files: payload.files || [],
  142. nextActions: payload.nextActions || [],
  143. warnings: payload.warnings || [],
  144. errors: payload.errors || [{ message }]
  145. };
  146. }
  147. function normalizeConfidence(value) {
  148. if (typeof value !== 'number' || Number.isNaN(value)) return 0.7;
  149. return Math.max(0, Math.min(1, value));
  150. }
  151. function normalizeMarker(item = {}) {
  152. return {
  153. area: item.area || item.location || item.space || '未指明区域',
  154. markerType: item.markerType || item.type || item.label || '未分类标记',
  155. value: item.value || item.text || '',
  156. evidence: item.evidence || item.reason || '',
  157. confidence: normalizeConfidence(item.confidence)
  158. };
  159. }
  160. function normalizeMissing(item = {}) {
  161. return {
  162. area: item.area || item.location || item.space || '未指明区域',
  163. missingType: item.missingType || item.markerType || item.type || '未说明缺失项',
  164. risk: item.risk || item.impact || '需要人工复核,避免后续尺寸整理漏项。',
  165. evidence: item.evidence || item.reason || '',
  166. confidence: normalizeConfidence(item.confidence)
  167. };
  168. }
  169. function normalizeUncertain(item = {}) {
  170. return {
  171. area: item.area || item.location || item.space || '未指明区域',
  172. reason: item.reason || item.evidence || '图像信息不足,暂无法确认。',
  173. action: item.action || '建议回看原始量尺图或现场照片后复核。',
  174. confidence: normalizeConfidence(item.confidence)
  175. };
  176. }
  177. function countByType(items) {
  178. return items.reduce((acc, item) => {
  179. const key = item.markerType || item.missingType || '未分类';
  180. acc[key] = (acc[key] || 0) + 1;
  181. return acc;
  182. }, {});
  183. }
  184. function buildMeasurementMarkerReport(input = {}) {
  185. const source = input.sample ? SAMPLE_ANALYSIS : input;
  186. const detectedMarkers = asArray(source.detectedMarkers).map(normalizeMarker);
  187. const missingMarkers = asArray(source.missingMarkers).map(normalizeMissing);
  188. const uncertainAreas = asArray(source.uncertainAreas).map(normalizeUncertain);
  189. const requiredMarkers = asArray(source.requiredMarkers).length
  190. ? source.requiredMarkers.map(String)
  191. : DEFAULT_REQUIRED_MARKERS;
  192. const status = missingMarkers.length ? 'needs_review' : 'ok';
  193. const pass = status === 'ok' && uncertainAreas.length === 0;
  194. const summary = {
  195. project: source.project || '装修量尺图缺标记检查',
  196. image: source.image || source.imageUrl || source.imagePath || '',
  197. sourceType: source.sourceType || 'image',
  198. pass,
  199. status,
  200. detectedCount: detectedMarkers.length,
  201. missingCount: missingMarkers.length,
  202. uncertainCount: uncertainAreas.length,
  203. detectedByType: countByType(detectedMarkers),
  204. missingByType: countByType(missingMarkers)
  205. };
  206. const assistantMessage = renderAssistantMessage({
  207. summary,
  208. requiredMarkers,
  209. detectedMarkers,
  210. missingMarkers,
  211. uncertainAreas,
  212. overallJudgement: source.overallJudgement
  213. });
  214. return {
  215. summary,
  216. data: {
  217. requiredMarkers,
  218. detectedMarkers,
  219. missingMarkers,
  220. uncertainAreas,
  221. overallJudgement: source.overallJudgement || defaultJudgement(summary)
  222. },
  223. assistantMessage
  224. };
  225. }
  226. function defaultJudgement(summary) {
  227. if (summary.missingCount > 0) {
  228. return '当前量尺图存在疑似未标记位置,建议先人工复核缺失项,再输出标准化尺寸表。';
  229. }
  230. if (summary.uncertainCount > 0) {
  231. return '当前未发现明确缺标,但仍有图像不清晰区域,需要人工确认。';
  232. }
  233. return '当前未发现明显缺标记,可进入下一步标准化整理。';
  234. }
  235. function renderAssistantMessage({ summary, requiredMarkers, detectedMarkers, missingMarkers, uncertainAreas, overallJudgement }) {
  236. const lines = [];
  237. lines.push('## 量尺图缺标记检查(P0)');
  238. lines.push('');
  239. lines.push(`**结论**:${overallJudgement || defaultJudgement(summary)}`);
  240. lines.push('');
  241. lines.push(`- 已识别标记:${summary.detectedCount} 项`);
  242. lines.push(`- 疑似缺标记:${summary.missingCount} 项`);
  243. lines.push(`- 待确认区域:${summary.uncertainCount} 项`);
  244. lines.push('');
  245. lines.push('### 本轮检查口径');
  246. requiredMarkers.forEach(item => lines.push(`- ${item}`));
  247. lines.push('');
  248. lines.push('### 疑似未标记位置');
  249. if (missingMarkers.length) {
  250. missingMarkers.forEach((item, index) => {
  251. lines.push(`${index + 1}. ${item.area}:缺少「${item.missingType}」`);
  252. lines.push(` - 风险:${item.risk}`);
  253. if (item.evidence) lines.push(` - 图像依据:${item.evidence}`);
  254. lines.push(` - 置信度:${Math.round(item.confidence * 100)}%`);
  255. });
  256. } else {
  257. lines.push('- 暂未发现明确缺标记。');
  258. }
  259. lines.push('');
  260. if (uncertainAreas.length) {
  261. lines.push('### 待人工复核区域');
  262. uncertainAreas.forEach((item, index) => {
  263. lines.push(`${index + 1}. ${item.area}:${item.reason}`);
  264. lines.push(` - 建议动作:${item.action}`);
  265. lines.push(` - 置信度:${Math.round(item.confidence * 100)}%`);
  266. });
  267. lines.push('');
  268. }
  269. if (detectedMarkers.length) {
  270. lines.push('### 已识别标记摘录');
  271. detectedMarkers.slice(0, 8).forEach((item, index) => {
  272. const value = item.value ? `:${item.value}` : '';
  273. const evidence = item.evidence ? `(${item.evidence})` : '';
  274. lines.push(`${index + 1}. ${item.area} / ${item.markerType}${value}${evidence}`);
  275. });
  276. if (detectedMarkers.length > 8) {
  277. lines.push(`- 另有 ${detectedMarkers.length - 8} 项已识别标记写入结构化结果。`);
  278. }
  279. lines.push('');
  280. }
  281. lines.push('### 下一步');
  282. if (missingMarkers.length || uncertainAreas.length) {
  283. lines.push('- 先让量尺/设计同事确认上述缺标或模糊区域。');
  284. lines.push('- 复核后补齐尺寸、限制条件或备注,再生成标准化尺寸对照表。');
  285. } else {
  286. lines.push('- 可继续输出标准化尺寸对照表和异常预警清单。');
  287. }
  288. return lines.join('\n');
  289. }
  290. function writeMeasurementMarkerReport(outputDir, report) {
  291. const absolute = path.resolve(outputDir || path.join('outputs', 'measurement-marker-check', new Date().toISOString().slice(0, 10)));
  292. fs.mkdirSync(absolute, { recursive: true });
  293. const jsonPath = path.join(absolute, 'measurement-marker-check-result.json');
  294. const mdPath = path.join(absolute, 'measurement-marker-check-report.md');
  295. fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf8');
  296. fs.writeFileSync(mdPath, report.assistantMessage, 'utf8');
  297. return [jsonPath, mdPath];
  298. }
  299. async function runMeasurementMarkerCheck(input = {}) {
  300. const normalized = normalizeInput(input);
  301. const outputDir = path.resolve(normalized.output || path.join('outputs', 'measurement-marker-check', new Date().toISOString().slice(0, 10)));
  302. if (!normalized.sample && !normalized.image && !normalized.imageUrl && !normalized.imagePath) {
  303. return errorResult('请提供量尺图片路径/URL,或使用 --sample 跑 P0 演示样例。', {
  304. nextActions: [
  305. '上传或传入一张量尺图',
  306. 'Claude 先基于图片输出 detectedMarkers / missingMarkers / uncertainAreas',
  307. '再调用本工具生成 P0 缺标记报告'
  308. ]
  309. });
  310. }
  311. if (!normalized.sample && !normalized.detectedMarkers.length && !normalized.missingMarkers.length && !normalized.uncertainAreas.length) {
  312. return {
  313. status: 'needs_vision_observation',
  314. assistantMessage: [
  315. '已收到量尺图片入口,但还缺少看图后的结构化观察。',
  316. '',
  317. '请先让 Claude 直接观察上传的量尺图,提取:',
  318. '- detectedMarkers:已标记的空间、尺寸、门窗、管道、梁柱、备注等',
  319. '- missingMarkers:疑似未标记的位置、缺失类型、风险、图像依据、置信度',
  320. '- uncertainAreas:图像模糊或无法确定的位置',
  321. '',
  322. '随后把这些结构化结果传给本工具,即可生成 P0 缺标记报告。'
  323. ].join('\n'),
  324. summary: {
  325. image: normalized.image || normalized.imageUrl || normalized.imagePath,
  326. needsVisionObservation: true
  327. },
  328. data: {
  329. expectedInputShape: {
  330. detectedMarkers: [{ area: '厨房右侧墙面', markerType: '关键尺寸', value: '3200mm', evidence: '墙面横向尺寸已标注', confidence: 0.9 }],
  331. missingMarkers: [{ area: '窗户下沿', missingType: '窗台高度', risk: '影响台面或窗帘盒设计', evidence: '窗户有轮廓但未见离地高度', confidence: 0.78 }],
  332. uncertainAreas: [{ area: '右上角', reason: '文字模糊', action: '人工复核原图', confidence: 0.58 }]
  333. }
  334. },
  335. files: [],
  336. nextActions: ['先进行图片视觉识别', '再调用 measurement marker check 工具生成报告'],
  337. warnings: ['P0 阶段不在本地工具内直接调用视觉模型,由 Claude Code 视觉能力或上游识图服务提供观察结果。'],
  338. errors: []
  339. };
  340. }
  341. const report = buildMeasurementMarkerReport(normalized);
  342. const files = writeMeasurementMarkerReport(outputDir, report);
  343. return okResult({
  344. assistantMessage: report.assistantMessage,
  345. summary: report.summary,
  346. data: report.data,
  347. files,
  348. nextActions: report.summary.missingCount || report.summary.uncertainCount
  349. ? ['让量尺/设计同事补充缺标或模糊区域', '补齐后继续输出标准化尺寸对照表']
  350. : ['继续输出标准化尺寸对照表', '可选:与人工标准表做差异对比'],
  351. warnings: normalized.sample ? ['当前使用 P0 sample 演示数据。'] : []
  352. });
  353. }
  354. async function main() {
  355. const args = parseArgs(process.argv.slice(2));
  356. try {
  357. const result = await runMeasurementMarkerCheck(args);
  358. console.log(JSON.stringify(result, null, 2));
  359. if (args.resultPrefix || args['result-prefix']) {
  360. const prefix = args.resultPrefix || args['result-prefix'];
  361. console.log(`${prefix}=${JSON.stringify(result)}`);
  362. }
  363. process.exit(result.status === 'ok' || result.status === 'needs_vision_observation' ? 0 : 1);
  364. } catch (error) {
  365. const result = errorResult(error.message || String(error));
  366. console.log(JSON.stringify(result, null, 2));
  367. process.exit(1);
  368. }
  369. }
  370. if (require.main === module) {
  371. main();
  372. }
  373. module.exports = {
  374. runMeasurementMarkerCheck,
  375. buildMeasurementMarkerReport,
  376. SAMPLE_ANALYSIS,
  377. DEFAULT_REQUIRED_MARKERS
  378. };