import type { IngestWarning, ReferenceMaster } from './reference-master.js' /* * **원본에서 읽은 뒤의 총평** — 사람이 한 장으로 읽는다. * * ── 왜 필요한가 (2026-08-22) ──────────────────────────────────────────────── * 읽기가 끝나면 화면에 남는 것은 경고 열 줄이었고, 그 안에 이유가 문장으로 묻혀 있고 UUID 수백 개가 * 붙어 있었다. 그래서 사람이 「가져올 것은 다 들어왔나」·「무엇이 비었나」·「다음에 무엇을 하면 * 나아지나」를 스스로 세어야 했다 — 실제로 그 셋을 손으로 세는 대화가 하루를 채웠다. * * 그 셋은 이미 우리가 아는 사실이다. 세어 두었으니 **정리해서 내면 된다.** * * ── 두 층으로 나눈다 ──────────────────────────────────────────────────────── * 여기는 **사실 층**이다: 센 것과 그 사유 코드만 담고 문장을 만들지 않는다. 화면이 표로 그리고, * 사람이 더 알고 싶으면 그때 AI 가 이 사실**만** 근거로 서술한다(말 층). * * **AI 는 로드베어링이 아니다.** 호출이 꺼져 있거나 실패해도 이 표가 그대로 화면에 남아야 한다 — * 그래서 사실 층이 스스로 완결이다. * * ── 무엇을 지어내지 않는가 ────────────────────────────────────────────────── * 「없음」의 뜻을 셋으로 구분해 둔다. 뭉치면 조치가 달라지는 것들이 한 칸에 섞인다. * · `gaps` — 원본에 있는데 우리가 옮기지 못했다. 고칠 대상이 있다(원본의 빈 칸이거나 우리 매핑). * · `absent` — 이 원본에 그 **개념이 없다.** 빠진 것이 아니므로 고칠 것이 없다. * · `grounding` — 값이 들어왔지만 **근거가 저장된 값이 아니다**(원본의 계산 규칙에서 왔다). */ /** 센 것 — 화면이 그대로 표로 그린다. */ export interface AssessmentCounts { locations: number areas: number equipment: number materialDefinitions: number operations: number /** 생산 선언이 없으면 없다 — 0 과 구별한다(선언 자체가 없는 것과 비어 있는 것은 다르다). */ materials?: number materialsWithHome?: number routes?: number recipes?: number recipesWithRoute?: number /** 공정을 말하는 투입 / 전체 투입 — 공정별 소요를 아는 정도. */ taggedInputs?: number totalInputs?: number /** 시험 명세(점검표)의 수 — 선언되지 않았으면 없다(0 과 구별한다). */ testSpecifications?: number /** 그 명세들이 선언한 판정 기준의 수. */ criteria?: number /** * 그중 **아무 한계도 말하지 않는** 기준의 수 — 커널의 `criterionSaysNothing` 과 같은 판정이다. * * 이 수가 0 이 아니면 「관리점이 있다」가 「판정된다」를 뜻하지 않는다. 세지 않으면 그 구별이 사라진다. */ criteriaWithoutLimit?: number } /** 옮기지 못한 것 한 갈래 — 건수 큰 것부터. */ export interface AssessmentGap { /** 무엇을 옮기다 못 옮겼나 — 커넥터가 붙인 이름(`material home warehouse`). */ what: string count: number /** 못 옮긴 원본 값 몇 개 — 전부가 아니라 표본이다(수백 개가 붙던 자리다). */ sample?: string /** 그 자리에 대신 들어간 값. */ fallback?: string } export interface ImportAssessment { source: string siteId: string siteName?: string system?: string built: AssessmentCounts /** * **원본이 가지고 있던 수** — 커넥터가 말해 준 경우에만 있다. * * 이름을 `origin` 이라 하지 않는다 — 화면에서 그 낱말이 이미 **트윈의 출처**(`summary.origin`: * 템플릿인가 원본인가)를 뜻한다. 같은 이름을 두 뜻으로 쓰면 읽는 사람이 헷갈린다. * * `built` 와 비교해 「전부 옮겼는가」를 사람이 직접 셀 수 있게 한다. 커넥터가 말하지 않으면 이 자리는 * 비어 있고, 화면은 분모 없이 옮긴 수만 보인다 — **분모를 지어내지 않는다.** */ originCounts?: Record gaps: AssessmentGap[] /** 이 원본에 개념이 없는 자리 — 커널 낱말로 적는다(화면이 번역한다). */ absent: string[] /** * 값이 들어왔지만 **근거가 원본의 저장된 값이 아닌 것** — 파생·유추된 값이다. * * 이것을 `gaps` 에 섞으면 안 된다. 빈 자리가 아니라 **채워진 자리**이고, 사람이 물을 것은 * 「왜 비었나」가 아니라 「그 값을 믿어도 되나」다. 조치도 다르다 — 원본을 채우는 것이 아니라 * 그 규칙이 바뀌면 이 값도 바뀐다는 사실을 아는 것이다. */ derived: AssessmentGap[] /** 되물은 횟수·잘린 목록 같은 운영 사실. */ notes: IngestWarning[] } /** 이 마스터가 개념을 선언했나 — 「없다」와 「비어 있다」를 구분한다. */ const declared = (v: unknown): boolean => Array.isArray(v) ? v.length > 0 : v !== undefined && v !== null /** * 마스터 한 벌 → 총평의 사실 층. * * 경고를 두 갈래로 나눈다: `connector-mapping-fallback` 은 **옮기지 못한 것**이므로 `gaps` 로, * 나머지(파생된 값·되물은 횟수·잘린 목록)는 `notes` 로 그대로 나른다. 판단하지 않는다 — 무엇이 * 심각한지는 건수와 그 자리의 뜻이 말하고, 그 해석은 사람이나 말 층의 몫이다. */ export function assessMaster(master: ReferenceMaster, siteId: string): ImportAssessment { const m: any = master const d = m.productionSpec?.definition const inputs = (d?.recipes ?? []).flatMap((r: any) => r.inputs ?? []) const built: AssessmentCounts = { locations: (m.locations ?? []).length, areas: (m.locations ?? []).filter((l: any) => l.role === 'area' || l.role === 'both').length, equipment: (m.equipment ?? []).length, materialDefinitions: (m.materialDefinitions ?? []).length, operations: (m.operations ?? []).length, ...(declared(m.testSpecifications) ? { testSpecifications: m.testSpecifications!.length, criteria: m.testSpecifications!.reduce((n, t) => n + (t.criteria?.length ?? 0), 0), /* * **한계를 말하지 않는 기준** — 커널의 `criterionSaysNothing` 과 같은 판정이다. 기준이 * 선언됐다는 사실만 있고 판정할 재료가 없는 자리이고, 그것을 세지 않으면 「관리점이 있다」가 * 「판정된다」로 읽힌다. */ criteriaWithoutLimit: m.testSpecifications!.reduce( (n, t) => n + (t.criteria ?? []).filter(c => !c.expression && c.limit?.minimum === undefined && c.limit?.maximum === undefined).length, 0 ) } : {}), ...(d ? { materials: (d.materials ?? []).length, materialsWithHome: (d.materials ?? []).filter((x: any) => x.locationType).length, routes: (d.routes ?? []).length, recipes: (d.recipes ?? []).length, recipesWithRoute: (d.recipes ?? []).filter((x: any) => x.route).length, totalInputs: inputs.length, taggedInputs: inputs.filter((i: any) => i.operation).length } : {}) } /* 원본의 수는 커넥터가 말한 그대로 나른다 — 여기서 고치거나 채우지 않는다. */ const originCounts = m.originCounts && Object.keys(m.originCounts).length ? { ...m.originCounts } : undefined const gaps: AssessmentGap[] = [] const derived: AssessmentGap[] = [] const notes: IngestWarning[] = [] /* * 커넥터가 「대체」 수집기로 남기는 것 중 일부는 **빈 자리가 아니라 파생된 값**이다(산출량이 원본의 * 계산 규칙에서 온 경우처럼). 같은 통로로 오지만 뜻이 달라 구분해 담는다 — 이름에 그 사실이 적혀 있다. */ const isDerived = (field: string) => /derived|not a stored field/i.test(field) for (const w of m.warnings ?? []) { if (w?.code === 'connector-mapping-fallback' && w.params?.field) { const values = String(w.params.values ?? '') const bin = isDerived(String(w.params.field)) ? derived : gaps bin.push({ what: String(w.params.field), count: Number(w.params.count ?? 0), /* 값은 표본만 — 이 자리에 UUID 수백 개가 붙어 화면을 밀어냈다. 전체는 경고 원문에 남는다. */ ...(values ? { sample: values.length > 120 ? `${values.slice(0, 120)}…` : values } : {}), ...(w.params.fallback ? { fallback: String(w.params.fallback) } : {}) }) continue } notes.push(w) } gaps.sort((a, b) => b.count - a.count) derived.sort((a, b) => b.count - a.count) /* * 이 원본에 개념이 없는 자리. **커널 낱말로 적는다** — 화면이 그것으로 번역하고, 개념 지도가 이미 * 같은 낱말을 쓴다(두 화면이 다른 말을 하면 사람이 둘을 다른 사실로 읽는다). */ const absent: string[] = [] if (!declared(m.persons)) absent.push('persons') if (!declared(m.assets)) absent.push('assets') if (!declared(m.testSpecifications)) absent.push('testSpecifications') if (!declared(m.personnelClasses)) absent.push('personnelClasses') if (!declared(m.assetClasses)) absent.push('assetClasses') if (!declared(m.materialClasses)) absent.push('materialClasses') if (!d) absent.push('productionSpec') return { source: m.source, siteId, siteName: m.siteName, system: m.system, built, ...(originCounts ? { originCounts } : {}), gaps, derived, absent, notes } } /** 사람이 읽는 줄 — 터미널·로그가 읽는다(화면은 구조를 그대로 그린다). */ export function formatAssessment(a: ImportAssessment): string { const c = a.built const out: string[] = [] /** * 분모가 있으면 함께 적는다 — 「1,261」과 「원본 1,408 중 1,261」은 다른 말이다. * 커넥터가 그 수를 말하지 않았으면 옮긴 수만 적는다(지어내지 않는다). */ const n = (key: keyof AssessmentCounts, v?: number) => { if (v === undefined) return '없음' const o = a.originCounts?.[key as string] return o === undefined ? String(v) : o === v ? `${v}(원본 전부)` : `${v} / 원본 ${o}` } out.push(`총평 ${a.siteName ?? a.siteId} (${a.system ?? '?'}) — 원본 ${a.source}`) out.push( ` 선 것: 로케이션 ${n('locations', c.locations)}(존 ${c.areas}) · 설비 ${n('equipment', c.equipment)} · ` + `품목 정의 ${n('materialDefinitions', c.materialDefinitions)} · 공정 ${n('operations', c.operations)}` ) if (c.materials !== undefined) { out.push( ` 자재 ${n('materials', c.materials)}(보관 로케이션 있는 것 ${c.materialsWithHome}) · ` + `경로 ${n('routes', c.routes)} · 레시피 ${n('recipes', c.recipes)}(경로 붙은 것 ${c.recipesWithRoute}) · ` + `투입 ${n('totalInputs', c.totalInputs)}(공정을 말하는 것 ${c.taggedInputs})` ) } else { out.push(' 생산 선언 없음 — 레시피가 하나도 성립하지 않았다') } if (a.gaps.length) { out.push(' 옮기지 못한 것(건수 순):') for (const g of a.gaps) out.push(` ${String(g.count).padStart(6)} ${g.what}${g.sample ? ` — ${g.sample}` : ''}`) } if (a.derived.length) { out.push(' 파생된 값(저장된 값이 아니다):') for (const g of a.derived) out.push(` ${g.what}${g.sample ? ` — ${g.sample}` : ''}`) } if (c.testSpecifications) { /* * 관리 기준은 계측이 오는지와 무관하게 값이 있다 — 「관리점이 셋인데 둘은 아무 한계도 말하지 * 않는다」를 여기서 볼 수 있어야 판정에 근거가 있는지 사람이 안다. */ out.push( ` 관리 기준: 점검표 ${n('testSpecifications', c.testSpecifications)} · 기준 ${c.criteria ?? 0}` + (c.criteriaWithoutLimit ? ` (한계를 말하지 않는 것 ${c.criteriaWithoutLimit})` : '') ) } if (a.absent.length) out.push(` 이 원본에 개념이 없는 자리: ${a.absent.join(', ')}`) for (const n of a.notes) out.push(` 참고 ${n.code}: ${n.message}`) return out.join('\n') }