/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/goal-to-layout.js
1 758 строк
91 KB
Maksim Ratnikov
fix: страница цели собирается на любом валидном и на кривом JSON
29 июл 2026, 12:30
29 июл 2026, 12:30
9684195
Код
Авторство
О чём код?
// goal-template.v1 JSON -> wiki-layout-v1 JSON. // // Модуль только ПОРОЖДАЕТ layout JSON: рендерер (layout-renderer.js) остаётся универсальным, // в его switch не добавляется ни одного goal-специфичного case, а обычный путь // «текст -> промт -> layout JSON» не затрагивается. // // Профили страницы: // public — читателю: провенанс показывается только у неподтверждённых полей; // audit — владельцу и аудитору: статусы у всего, плюс покрытие источников, // упоминания сущностей и цитаты. /* ------------------------------------------------------------------ labels */ const STATUS_META = { confirmed: { label: "подтверждено", color: "green" }, inferred: { label: "выведено", color: "blue" }, requires_review: { label: "требует проверки", color: "yellow" }, conflicting: { label: "конфликт", color: "red" }, missing: { label: "не найдено", color: "gray" } }; const CONFIDENCE_LABEL = { high: "высокая", medium: "средняя", low: "низкая", none: "не определена" }; const ENTITY_TYPE_LABEL = { as: "АС", fp: "ФП", module: "модуль", agent: "агент", team: "команда", cluster: "кластер", artificial_block: "искусственный блок", api: "API", service: "сервис", integration: "интеграция", other: "другое" }; const CONTACT_TYPE_LABEL = { goal_owner: "Владелец цели", process_owner: "Владелец процесса", metric_owner: "Владелец метрики", registry_owner: "Владелец реестра", support_unit: "Поддержка (подразделение)", support_person: "Поддержка (человек)", escalation: "Эскалация", communication_channel: "Канал связи", other: "Другое" }; const ASSIGNEE_KIND_LABEL = { person: "человек", unit: "подразделение", channel: "канал", role: "роль" }; const RELATION_LABEL = { aggregates: "агрегирует", realizes: "реализует", depends_on: "зависит от", blocks: "блокирует", influences: "влияет на", derived_from: "производная от", related_to: "связана с" }; const OBSTACLE_KIND_LABEL = { source_quality: "качество источника", data_conflict: "конфликт данных", missing_information: "нехватка информации", technical_constraint: "техническое ограничение", process_blocker: "процессный блокер", external_dependency: "внешняя зависимость", other: "другое" }; const SEVERITY_LABEL = { blocker: "блокер", major: "существенное", minor: "незначительное", info: "информация" }; const SCOPE_LABEL = { extraction: "извлечение", goal_execution: "исполнение цели", downstream_resolution: "смежные решения" }; const COVERAGE_LABEL = { used: "использован", reviewed_no_direct_goal_facts: "просмотрен, прямых фактов нет", partially_used: "использован частично", conflicting: "конфликтует", requires_review: "требует проверки" }; const MATCH_METHOD_LABEL = { exact_ci: "по CI-коду", exact_alias: "по псевдониму", exact_name: "по точному названию", fuzzy_name: "по похожему названию", manual: "вручную", unresolved: "не разрешено" }; const SET_KIND_LABEL = { wave: "волна", quarter_scope: "объём квартала", registry: "реестр", manual_list: "ручной список", cluster_scope: "объём кластера", team_scope: "объём команды", artificial_block: "искусственный блок", source_table: "таблица источника", other: "другое" }; const EXTRACTION_STATUS_LABEL = { complete: "извлечено полностью", partial: "извлечено частично", extraction_blocked: "извлечение заблокировано", requires_source_refresh: "нужна свежая выгрузка" }; const SOURCE_TYPE_LABEL = { pdf: "PDF", html: "HTML", xlsx: "XLSX", wiki: "wiki", dashboard: "дашборд", system: "система", manual_note: "заметка", other: "другое" }; const COMPARATOR_LABEL = { eq: "=", gte: "≥", lte: "≤", gt: ">", lt: "<", between: "в диапазоне", approx: "≈" }; const AGGREGATION_LABEL = { sum: "сумма", avg: "среднее", median: "медиана", min: "минимум", max: "максимум", ratio: "доля", count_distinct: "уникальные значения", weighted_avg: "взвешенное среднее", other: "другое" }; const OP_LABEL = { eq: "=", in: "входит в", contains: "содержит", exists: "задан", gte: "≥", lte: "≤" }; const SOURCE_STATUS_LABEL = { draft: "черновик", confirmed: "подтверждено", mixed: "смешанный", requires_review: "требует проверки" }; /* ----------------------------------------------------------------- helpers */ const isFilled = (value) => Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ""; // Схема требует строку в title, name, label и подобных полях, но LLM регулярно оборачивает их // в textField `{value, state}` — вокруг ведь все текстовые поля такие. Принимаем оба вида: // иначе одна такая ошибка модели рушит всю страницу. export function plainText(value) { if (value === null || value === undefined) return ""; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (typeof value === "object" && typeof value.value === "string") return value.value; return ""; } // Поля, которые по схеме обязаны быть строкой. Если LLM прислала объект, страница всё равно // собирается, но пользователь должен об этом узнать. export function goalShapeProblems(goal) { const problems = []; const check = (value, path) => { if (value === undefined || value === null || typeof value === "string") return; problems.push(`${path}: ожидается строка, пришёл ${Array.isArray(value) ? "массив" : typeof value}`); }; (goal.metrics?.primary_metrics ?? []).forEach((metric, index) => check(metric.title, `/metrics/primary_metrics/${index}/title`)); (goal.obstacles ?? []).forEach((obstacle, index) => check(obstacle.title, `/obstacles/${index}/title`)); (goal.source_refs ?? []).forEach((source, index) => check(source.title, `/source_refs/${index}/title`)); (goal.applicability?.explicit_target_sets ?? []).forEach((set, index) => check(set.title, `/applicability/explicit_target_sets/${index}/title`)); (goal.responsibility?.contacts ?? []).forEach((contact, index) => check(contact.name, `/responsibility/contacts/${index}/name`)); for (const [key, actions] of [["required_actions", goal.execution?.required_actions], ["recommended_actions", goal.execution?.recommended_actions]]) { (actions ?? []).forEach((action, index) => check(action.title, `/execution/${key}/${index}/title`)); } return problems; } function statusChip(state) { const meta = STATUS_META[state?.status] ?? { label: state?.status ?? "—", color: "gray" }; return { type: "status", title: meta.label, color: meta.color }; } // Provenance as inlines: a status chip plus (in audit profile) confidence and source ids. // Rendering ids inside `code` inlines is what kills the Markdown underscore problem — // `src_01` never passes through an emphasis parser. function provenance(state, { profile, always = false } = {}) { if (!state) return []; if (profile === "public" && !always && state.status === "confirmed") return []; const parts = [statusChip(state)]; if (profile === "audit") { if (state.confidence && state.confidence !== "none") { parts.push({ type: "text", text: " уверенность: " }, { type: "em", text: CONFIDENCE_LABEL[state.confidence] }); } if (isFilled(state.source_ref_ids)) { parts.push({ type: "text", text: "; источники: " }); state.source_ref_ids.forEach((id, index) => { if (index > 0) parts.push({ type: "text", text: ", " }); parts.push({ type: "code", text: id }); }); } } if (state.notes) parts.push({ type: "text", text: ` — ${state.notes}` }); return parts; } // In the public profile a table full of green "подтверждено" chips is noise: // only non-confirmed rows get marked. The audit profile marks every row. function statusCell(state, options) { if (options?.profile === "public" && state?.status === "confirmed") return ""; return { inlines: provenance(state, { ...options, always: true }) }; } // A textField becomes a value paragraph plus, when there is anything to say, a provenance line. // With showEmpty, a field that has no value but a non-confirmed state is still rendered: for a // partial JSON "что именно не найдено и почему" is the most valuable thing on the page. function textFieldBlocks(field, { profile, label, showEmpty = false } = {}) { if (!field) return []; if (!isFilled(field.value)) { if (!showEmpty || !field.state || field.state.status === "confirmed") return []; const head = label ? [{ type: "strong", text: `${label}: ` }] : []; return [{ type: "paragraph", inlines: [...head, ...provenance(field.state, { profile, always: true })] }]; } const blocks = []; const lead = label ? [{ type: "strong", text: `${label}: ` }, { type: "text", text: field.value }] : [{ type: "text", text: field.value }]; blocks.push({ type: "paragraph", inlines: lead }); const prov = provenance(field.state, { profile }); if (prov.length) blocks.push({ type: "paragraph", inlines: prov }); if (isFilled(field.conflicting_values)) { blocks.push({ type: "panel", panelType: "warning", title: "Конфликтующие варианты", blocks: [{ type: "table", headers: ["Источник", "Вариант", "Уверенность", "Примечание"], columnwidths: [200, 700, 200, 400], rows: field.conflicting_values.map((cv) => [ { inlines: [{ type: "code", text: cv.source_ref_id }] }, cv.value ?? "", CONFIDENCE_LABEL[cv.confidence] ?? "", cv.notes ?? "" ]) }] }); } return blocks; } function heading(level, text) { return { type: "heading", level, text }; } // Drops any column that is empty in every row — the first column always stays. Partial goal // JSONs leave whole columns blank (no CI codes, everything confirmed, no notes), and an empty // column is pure noise on the page. function table(headers, inputRows, columnwidths) { // Ячейка, в которую по ошибке модели попал textField, превращается в его текст, // а не роняет рендер сообщением «Unsupported table cell format». // Пустая таблица (все строки отфильтрованы) не должна появляться на странице шапкой без данных. if (!(inputRows ?? []).length) return null; const rows = (inputRows ?? []).map((row) => row.map((cell) => ( cell && typeof cell === "object" && !Array.isArray(cell) && cell.text === undefined && !cell.inlines && !cell.status && typeof cell.value === "string" ? cell.value : cell ))); const isEmpty = (cell) => cell === "" || cell === undefined || cell === null || (typeof cell === "object" && !Array.isArray(cell) && Array.isArray(cell.inlines) && cell.inlines.length === 0); const keep = headers.map((_, index) => index === 0 || !rows.length || !rows.every((row) => isEmpty(row[index]))); const trimmedHeaders = headers.filter((_, index) => keep[index]); const trimmedRows = rows.map((row) => row.filter((_, index) => keep[index])); // Таблица из одной строки — это один факт с подписями, а не таблица: шапка ради // единственной строки создаёт ощущение перегруженной страницы на пустом месте. if (trimmedRows.length === 1 && trimmedHeaders.length > 1) { const inlines = []; trimmedHeaders.forEach((header, index) => { const cell = trimmedRows[0][index]; const empty = cell === "" || cell === undefined || cell === null || (typeof cell === "object" && !Array.isArray(cell) && Array.isArray(cell.inlines) && cell.inlines.length === 0); if (empty) return; if (inlines.length) inlines.push({ type: "text", text: " · " }); inlines.push({ type: "strong", text: `${header}: ` }); if (typeof cell === "string") inlines.push({ type: "text", text: cell }); else if (Array.isArray(cell)) inlines.push(...cell); else if (cell.inlines) inlines.push(...cell.inlines); else if (cell.text !== undefined) inlines.push({ type: "text", text: cell.text }); }); return inlines.length ? { type: "paragraph", inlines } : null; } // Таблица, от которой осталась одна колонка, — это список: рисуем его списком. if (trimmedHeaders.length === 1) { return { type: "orderedList", items: trimmedRows.map((row) => (typeof row[0] === "string" ? { text: row[0] } : { inlines: row[0]?.inlines ?? [{ type: "text", text: String(row[0] ?? "") }] })) }; } const widths = Array.isArray(columnwidths) ? columnwidths.filter((_, index) => keep[index]) : columnwidths; if (Array.isArray(widths) && widths.length) { const sum = widths.reduce((acc, width) => acc + width, 0); const scaled = widths.map((width) => Math.max(60, Math.round((width / sum) * 1500))); scaled[scaled.length - 1] += 1500 - scaled.reduce((acc, width) => acc + width, 0); return { type: "table", headers: trimmedHeaders, rows: trimmedRows, columnwidths: scaled }; } return { type: "table", headers: trimmedHeaders, rows: trimmedRows }; } function taskListFromActions(actions, { profile }) { return { type: "taskList", items: actions.map((action) => ({ checked: false, text: [action.title, action.role ? `(${action.role})` : "", action.details ? `— ${action.details}` : "", profile === "audit" && action.state?.status && action.state.status !== "confirmed" ? `[${STATUS_META[action.state.status]?.label ?? action.state.status}]` : ""] .filter(Boolean).join(" ") })) }; } function linkButton(link, { icon = "Link", color = "Foreground.foreAccent" } = {}) { return { type: "uiButton", title: link.label ?? link.title ?? link.url, url: link.url, tooltip: link.url, color, size: "md", icon, display: "", newwindow: true }; } function selectorText(selector) { if (!selector) return ""; switch (selector.kind) { case "all_of_type": return "все сущности указанных типов"; case "manual_set": return `ручной набор: ${(selector.entity_ids ?? []).join(", ") || "—"}`; case "explicit_entities": return `перечисление: ${(selector.entity_ids ?? []).join(", ")}`; case "attribute_filter": return Object.entries(selector.attributes ?? {}) .map(([key, cond]) => `${key} ${OP_LABEL[cond?.op] ?? cond?.op ?? ""} ${formatScalar(cond?.value)}`.trim()) .join("; "); case "relation_filter": { const relation = selector.relation ?? {}; const target = relation.target_entity_type ? ` → ${ENTITY_TYPE_LABEL[relation.target_entity_type] ?? relation.target_entity_type}` : ""; const id = relation.target_entity_id ? ` (${relation.target_entity_id})` : ""; const scope = relation.scope ? `, ${relation.scope}` : ""; return `связь «${relation.type}»${target}${id}${scope}`; } case "source_list": return `список из источника: ${selector.source_list_ref}`; case "textual_rule": return selector.text ?? ""; case "compound": return (selector.sub_selectors ?? []).map(selectorText).join(selector.combinator === "and" ? " И " : " ИЛИ "); default: return selector.kind; } } function formatScalar(value) { if (value === undefined) return ""; if (Array.isArray(value)) return value.join(", "); if (value === null) return "null"; return String(value); } function typedValueText(typed) { if (!typed) return ""; const comparator = typed.comparator ? `${COMPARATOR_LABEL[typed.comparator] ?? typed.comparator} ` : ""; const range = typed.comparator === "between" ? `${typed.comparator_lower}…${typed.comparator_upper}` : formatScalar(typed.value); const unit = typed.unit ? ` ${typed.unit}` : ""; const aggregation = typed.aggregation ? `, агрегация: ${AGGREGATION_LABEL[typed.aggregation] ?? typed.aggregation}` : ""; const rounding = typed.rounding ? `, округление: ${typed.rounding}` : ""; return `${comparator}${range}${unit}${aggregation}${rounding}`.trim(); } // Ключи в attributes/dimensions приходят из источника как есть. Латинские snake_case-ключи // (representatives, window_days) — машинные поля: читателю показываем только значения. function keyValueText(map, { profile } = {}) { return Object.entries(map ?? {}) .map(([key, value]) => (profile !== "audit" && /^[a-z][a-z0-9_]*$/.test(key) ? formatScalar(value) : `${key}: ${formatScalar(value)}`)) .join("; "); } function entityTypeList(types) { return (types ?? []).map((type) => ENTITY_TYPE_LABEL[type] ?? type).join(", "); } /* ------------------------------------------------------------- links */ const LINK_GROUPS = { dashboard: { title: "Дашборды и витрины", icon: "Search", color: "Foreground.foreAccent" }, // Расчёт — это документ, а не согласование: позитивный «Approved» по контракту // закреплён за доступом, заявкой и готовностью. calculation: { title: "Детальные расчёты", icon: "File", color: "Foreground.foreAccent" }, access: { title: "Доступы и заявки", icon: "Approved", color: "Foreground.forePositive" }, instruction: { title: "Методики, регламенты, стандарты", icon: "File", color: "Background.surfaceSecondary" }, artifact: { title: "Артефакты и подтверждения", icon: "Link", color: "Foreground.foreAccent" }, contact: { title: "Контакты и каналы", icon: "Mail", color: "Foreground.forePositive" }, source: { title: "Прочее", icon: "Link", color: "Background.surfaceSecondary" } }; const LINK_GROUP_ORDER = ["dashboard", "calculation", "access", "instruction", "artifact", "contact", "source"]; // Ссылки — навигационная основа страницы, поэтому они собираются со всего документа в один // раздел и дедуплицируются по URL: один адрес = одна строка, даже если он упомянут в // нескольких местах (у метрики, в инструкциях и в источниках). // Одна и та же страница часто приходит и прямой ссылкой, и ссылкой-редиректом с адресом // внутри параметра. Для читателя это одна кнопка, поэтому дедуп идёт по конечному адресу, // а показывается прямая ссылка как более короткая и понятная. export function canonicalUrl(url) { if (!url) return url; const match = /[?&]sourceUrl=([^&]+)/i.exec(url); if (!match) return url; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } export function collectLinks(goal) { const byUrl = new Map(); const add = (url, label, group, context, state) => { if (!url) return; const key = canonicalUrl(url); const existing = byUrl.get(key); if (existing) { if (context && !existing.contexts.includes(context)) existing.contexts.push(context); if (!existing.state && state) existing.state = state; // прямая ссылка вытесняет редирект if (existing.url !== key && url === key) { existing.url = url; existing.label = label || existing.label; } return; } byUrl.set(key, { url, label: label || url, group, contexts: context ? [context] : [], state }); }; for (const metric of goal.metrics?.primary_metrics ?? []) { const playbook = metric.calculation_playbook ?? {}; // Название метрики нередко уже начинается со слова «Метрика» — не удваиваем его. const metricName = plainText(metric.title); const where = /^метрик/i.test(metricName) ? metricName : `Метрика «${metricName}»`; for (const link of playbook.dashboards ?? []) add(link.url, link.label, "dashboard", where, link.state); for (const link of playbook.access_instructions ?? []) { // В access_instructions попадают и страницы расчёта, и заявки на доступ — различаем по смыслу подписи. const isAccess = /доступ|заявк|запрос|учётн|access|request/i.test(link.label ?? ""); add(link.url, link.label, isAccess ? "access" : "calculation", where, link.state); } for (const contact of playbook.contacts ?? []) add(contact.url, contact.name, "contact", where, contact.state); } for (const link of goal.knowledge?.instructions ?? []) add(link.url, link.label, "instruction", "Общие материалы по цели", link.state); const standards = goal.standards_and_requirements ?? {}; for (const [group, items] of [["Стандарт", standards.standards], ["Требование", standards.requirements], ["Процесс", standards.processes]]) { for (const item of items ?? []) add(item.url, item.title, "instruction", `${group}: ${item.title}`, item.state); } const completion = goal.completion ?? {}; for (const [kind, items] of [["Закрывающий артефакт", completion.closing_artifacts], ["Пример подтверждения", completion.evidence_examples]]) { for (const item of items ?? []) add(item.url, item.title, "artifact", kind, item.state); } for (const contact of goal.responsibility?.contacts ?? []) { add(contact.url, contact.name, "contact", CONTACT_TYPE_LABEL[contact.contact_type] ?? "Контакт", contact.state); } for (const source of goal.source_refs ?? []) add(source.url, source.title, "source", "Источник", null); return Array.from(byUrl.values()); } // Ссылку, содержимое которой не проверялось, нельзя красить в позитивный цвет: // зелёная кнопка «Approved» читается как «проверено и годно». function linkButtonStyle(link, meta) { if (/\.xlsx?($|\?)/i.test(link.url)) return { icon: "Download", color: "sky.sky500" }; if (link.state && link.state.status !== "confirmed") return { icon: "Question", color: "Foreground.foreWarning" }; return { icon: meta.icon, color: meta.color }; } function linksBlocks(goal, options) { const links = collectLinks(goal); if (!links.length) return []; const blocks = [heading(2, "Куда идти")]; blocks.push({ type: "paragraph", inlines: [{ type: "text", text: `Все ссылки, встреченные в источниках цели: ${links.length}. Содержимое страниц по ссылкам при извлечении не проверялось.` }] }); for (const groupKey of LINK_GROUP_ORDER) { const groupLinks = links.filter((link) => link.group === groupKey); if (!groupLinks.length) continue; const meta = LINK_GROUPS[groupKey]; blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: meta.title }] }); // Читателю ссылка нужна как кнопка, аудитору — как адрес с провенансом. // Печатать и таблицу, и кнопку в одном профиле — дублирование. if (options.profile === "audit") { blocks.push(table( ["Ссылка", "Зачем нужна", "Адрес", "Статус"], groupLinks.map((link) => [ link.label, truncate(link.contexts.join("; "), 70), { inlines: [{ type: "code", text: link.url }] }, statusCell(link.state, options) ]), [420, 380, 520, 180] )); continue; } // Контракт: кнопки группируем по смыслу и не ставим длинным сплошным столбцом. const buttons = groupLinks.map((link) => linkButton(link, linkButtonStyle(link, meta))); for (let index = 0; index < buttons.length; index += 3) { const chunk = buttons.slice(index, index + 3); blocks.push(chunk.length === 1 ? chunk[0] : { type: "blocksGrid", columns: chunk.map((button) => [button]) }); } } return blocks; } /* --------------------------------------------------- cross-cutting issues */ // Walks the whole document and collects every evidenceState that is not "confirmed". // This is the part a prompt cannot do reliably: it is an exhaustive traversal, not a judgement. export function collectIssues(goal) { const issues = []; const SECTION_LABEL = { goal_identity: "Паспорт", goal_meaning: "Смысл цели", timeline: "Сроки", metrics: "Метрики", standards_and_requirements: "Стандарты и требования", applicability: "Применимость", entity_mentions: "Упоминания сущностей", responsibility: "Ответственные", execution: "Действия", implementation_details: "Детали реализации", completion: "Закрытие", knowledge: "Знания", obstacles: "Блокеры", goal_relations: "Связи целей", outcomes: "Результаты", source_coverage: "Покрытие источников", source_refs: "Источники" }; const FIELD_LABEL = { description: "Описание", business_reason: "Зачем нужна", expected_effect: "Что даёт", target_state: "Целевое состояние", dod: "Definition of Done", metric_formula: "Формула закрытия", acceptance_notes: "Примечания приёмки", refresh_cadence: "Периодичность расчёта", calculation_period_cutoff: "Отсечка расчётного периода", requirement_text: "Текст требования", impact: "Влияние", annual_target: "Годовая цель", numerator: "Числитель", denominator: "Знаменатель" }; function walk(node, section, trail, fromKey) { if (Array.isArray(node)) { node.forEach((item) => walk(item, section, trail, fromKey)); return; } if (!node || typeof node !== "object") return; const label = node.title ?? node.label ?? node.question ?? node.raw_text ?? node.raw_name ?? node.name ?? node.source_ref_id ?? node.metric_id ?? node.period_id ?? node.rule_id ?? node.criterion_id ?? node.requirement_id ?? node.obstacle_id ?? node.outcome_id ?? node.relation_id ?? null; const nextTrail = typeof label === "string" ? label : (typeof node.value === "string" && node.value.trim() ? node.value : (FIELD_LABEL[fromKey] ?? trail)); // Detail fields of an object that already reported an issue would duplicate it // (e.g. an obstacle's own state plus its description.state saying the same thing). const DETAIL_KEYS = new Set(["description", "impact", "requirement_text", "title"]); let reported = false; if (node.state && typeof node.state === "object" && "status" in node.state && node.state.status !== "confirmed") { issues.push({ section: SECTION_LABEL[section] ?? section, status: node.state.status, subject: (typeof nextTrail === "string" ? nextTrail : "") || "(без названия)", notes: node.state.notes ?? "" }); reported = true; } for (const [key, child] of Object.entries(node)) { if (key === "state") continue; if (reported && DETAIL_KEYS.has(key)) continue; walk(child, section, nextTrail, key); } } for (const [section, node] of Object.entries(goal)) { if (typeof node === "object" && node !== null) walk(node, section, null, section); } return issues; } // Сколько всего evidenceState в документе и как они распределены по статусам — // для карточки «наполнение» в шапке страницы. export function countStates(goal) { const counts = { confirmed: 0, inferred: 0, requires_review: 0, conflicting: 0, missing: 0 }; const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk); if (!node || typeof node !== "object") return; for (const [key, child] of Object.entries(node)) { if (key === "state" && child && typeof child === "object" && "status" in child) { if (child.status in counts) counts[child.status] += 1; continue; } walk(child); } }; walk(goal); counts.total = Object.values(counts).reduce((sum, value) => sum + value, 0); return counts; } function truncate(text, max = 90) { const value = String(text ?? ""); return value.length > max ? `${value.slice(0, max - 1)}…` : value; } /* -------------------------------------------------------------- sections */ // Шапка страницы: три карточки в ряд вместо таблицы «поле — значение». // Первое, что видит читатель: о чём цель, чем меряется, насколько данные полны. function headerCardBlocks(goal, options, { withToc = true } = {}) { const identity = goal.goal_identity ?? {}; const annual = goal.timeline?.annual_target; const owner = (goal.responsibility?.contacts ?? []).find((contact) => contact.contact_type === "goal_owner"); const metrics = goal.metrics?.primary_metrics ?? []; const counts = countStates(goal); // Контракт для верхней сетки: в широкой колонке — информационная таблица внутри panel. const row = (label, value) => (isFilled(value) ? [[label, value]] : []); const aboutRows = [ ...row("Период", identity.period), ...row("Годовая цель", annual ? `${annual.target_value ?? ""} ${annual.target_text ?? ""}`.trim() : ""), ...row("Владелец", owner ? `${owner.name}${owner.unit ? `, ${owner.unit}` : ""}` : ""), ...row("Область применения", entityTypeList(goal.applicability?.target_entity_types)), ...row("Другие названия", isFilled(identity.aliases) ? (identity.aliases ?? []).join(", ") : "") ]; const about = aboutRows.length ? [table(["Поле", "Значение"], aboutRows, [420, 1080])] : [{ type: "paragraph", text: "Паспортные поля цели в источниках не описаны." }]; // В карточке шапки метрике хватает названия и цели: владелец и периодичность — // в сводной таблице раздела «Метрики и расчёт», дублировать их здесь незачем. const metricRows = metrics.map((metric) => [ plainText(metric.title), metric.typed_value ? typedValueText(metric.typed_value) : "целевое значение не задано" ]); const metricLines = metricRows.length ? [table(["Метрика", "Целевое значение"], metricRows, [960, 540])] : [{ type: "paragraph", text: "Метрики в источниках не описаны." }]; // Третья карточка отвечает на вопрос «что сейчас», а не «насколько полно извлечены данные»: // счётчики провенанса ничего не говорят читателю и уходят в профиль «аудит». const stateLines = options.profile === "audit" ? provenanceSummaryLines(counts, goal) : currentStateLines(goal); // Контракт для больших страниц: после h1 идёт верхний blocksGrid — узкая колонка с toc, // широкая с информационной панелью. Остальные карточки — вторым рядом. return [ { type: "blocksGrid", template: "minmax(240px, 300px) minmax(0, 1fr)", // В оглавление берём только разделы верхнего уровня: подзаголовки и подписи внутри // разделов там только мешают. columns: withToc ? [ [{ type: "toc", minlevel: 2, maxlevel: 2 }], [{ type: "panel", panelType: "info", title: "О цели", blocks: about.length ? about : [{ type: "paragraph", text: "—" }] }] ] : [[{ type: "panel", panelType: "info", title: "О цели", blocks: about.length ? about : [{ type: "paragraph", text: "—" }] }]] }, { type: "blocksGrid", columns: [ [{ type: "panel", panelType: "tip", title: metrics.length > 1 ? `Метрики (${metrics.length})` : "Метрика", blocks: metricLines }], [{ type: "panel", panelType: "note", title: options.profile === "audit" ? "Наполнение данных" : "Текущее состояние", blocks: stateLines }] ] } ]; } // «Что сейчас с целью»: последний результат, последнее наблюдение или индикатор. // Если ничего этого нет — прямо говорим, что оценить состояние не по чему. function currentStateLines(goal) { const lines = []; const byDate = (items, key) => [...(items ?? [])].sort((a, b) => String(b[key] ?? "").localeCompare(String(a[key] ?? ""))); const outcome = byDate(goal.outcomes, "achieved_at")[0]; if (outcome) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Последний результат: " }, { type: "text", text: `${outcome.title}${outcome.achieved_at ? ` (${outcome.achieved_at})` : ""}` } ] }); } const observation = byDate(goal.metrics?.baseline_observations, "observed_at")[0]; if (observation) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Последнее измерение: " }, { type: "text", text: `${observation.label}${observation.observed_at ? ` (${observation.observed_at})` : ""} — ${observationText(observation.values)}` } ] }); } for (const indicator of (goal.metrics?.indicator_states ?? []).slice(0, 2)) { if (!isFilled(indicator.value)) continue; lines.push({ type: "paragraph", inlines: [{ type: "text", text: truncate(indicator.value, 160) }] }); } const blockers = (goal.obstacles ?? []).filter((obstacle) => obstacle.severity === "blocker" || obstacle.severity === "major"); if (blockers.length) { lines.push({ type: "paragraph", inlines: [ { type: "strong", text: "Мешает: " }, { type: "text", text: blockers.map((obstacle) => obstacle.title).slice(0, 2).join("; ") } ] }); } if (!lines.length) { lines.push({ type: "paragraph", inlines: [ { type: "text", text: "Фактических значений, результатов и индикаторов в источниках нет — оценить текущее состояние не по чему." } ] }); } return lines; } // Ключи в `values` приходят из источника как есть (иногда латиницей: status, cause). // Если все значения текстовые, ключи ничего не добавляют — печатаем только значения. function observationText(values) { const entries = Object.entries(values ?? {}); if (!entries.length) return ""; const allText = entries.every(([, value]) => typeof value === "string"); return allText ? entries.map(([, value]) => value).join("; ") : keyValueText(values); } function provenanceSummaryLines(counts, goal) { const fillLine = []; for (const status of ["confirmed", "inferred", "requires_review", "conflicting", "missing"]) { if (!counts[status]) continue; if (fillLine.length) fillLine.push({ type: "text", text: " " }); fillLine.push(statusChip({ status }), { type: "text", text: ` ${counts[status]}` }); } return [ { type: "paragraph", inlines: fillLine.length ? fillLine : [{ type: "text", text: "нет данных" }] }, { type: "paragraph", inlines: [{ type: "text", text: `Всего полей с провенансом: ${counts.total}. Источников: ${(goal.source_refs ?? []).length}.` }] } ]; } // Паспорт остаётся только в профиле «аудит»: в публичном всё нужное уже в карточках шапки, // а вторая таблица с теми же значениями — чистый дубль. function passportBlocks(goal, { profile }) { if (profile !== "audit") return []; const identity = goal.goal_identity ?? {}; const annual = goal.timeline?.annual_target; const owner = (goal.responsibility?.contacts ?? []).find((contact) => contact.contact_type === "goal_owner"); const rows = [ ["Идентификатор", { inlines: [{ type: "code", text: identity.id ?? "" }] }], ["Период", identity.period ?? ""], ["Статус источников", SOURCE_STATUS_LABEL[identity.source_status] ?? identity.source_status ?? ""] ]; if (isFilled(identity.aliases)) rows.push(["Другие названия", (identity.aliases ?? []).join(", ")]); if (annual) rows.push(["Годовая цель", `${annual.target_text}${annual.target_value ? ` (${annual.target_value})` : ""}`]); if (owner) rows.push(["Владелец цели", `${owner.name}${owner.unit ? `, ${owner.unit}` : ""}`]); if (isFilled(goal.applicability?.target_entity_types)) { rows.push(["Область применения", entityTypeList(goal.applicability.target_entity_types)]); } rows.push(["Версия шаблона", { inlines: [{ type: "code", text: `${goal.schema_version} / ${goal.schema_minor_version}` }] }]); return [heading(2, "Паспорт"), table(["Поле", "Значение"], rows, [400, 1100])]; } // Пробелы, которые меняют решения. Голый чип «не найдено» ничего не объясняет, поэтому // каждый пробел выводится тройкой «чего нет — почему важно — что делать». const CRITICAL_GAPS = [ { id: "goal_owner", what: "Владелец цели не назначен", why: "Некому принимать решения по спорным значениям, подтверждать корректировки и отвечать за итог.", action: "Уточнить владельца цели и зафиксировать его в источнике.", missing: (goal) => !(goal.responsibility?.contacts ?? []).some((contact) => contact.contact_type === "goal_owner") }, { id: "annual_target", what: "Годовое целевое значение не задано", why: "Без него нельзя сказать, достигнута цель или нет.", action: "Взять значение из методики или запросить у владельца цели.", missing: (goal) => !goal.timeline?.annual_target && !(goal.timeline?.quarterly_targets ?? []).some((target) => /год|year|^y\d/i.test(`${target.label ?? ""} ${target.period_id ?? ""}`)) }, { id: "metric_formula", what: "Формула закрытия цели не определена", why: "Есть формулы отдельных метрик, но не описано, как из них складывается результат цели.", action: "Согласовать правило агрегации метрик с владельцем цели.", missing: (goal) => !isFilled(goal.completion?.metric_formula?.value) }, { id: "dod", what: "Definition of Done не описан", why: "Непонятно, какие свидетельства принимаются при подведении итогов.", action: "Зафиксировать условия приёмки до периода фиксации.", missing: (goal) => !isFilled(goal.completion?.dod?.value) }, { id: "applicability", what: "Правила применимости не описаны", why: "Команда не может проверить, попадает ли она под цель.", action: "Уточнить, кому цель устанавливается и какие есть исключения.", missing: (goal) => !isFilled(goal.applicability?.inclusion_rules) && !isFilled(goal.applicability?.explicit_target_sets) }, { id: "actuals", what: "Фактические значения метрик не собраны", why: "Без измерений нельзя оценить, где цель находится сейчас.", action: "Выгрузить значения с дашборда и внести их в цель.", missing: (goal) => !isFilled(goal.metrics?.baseline_observations) && !isFilled(goal.outcomes) } ]; export function collectGaps(goal) { return CRITICAL_GAPS.filter((gap) => gap.missing(goal)); } function attentionBlocks(goal, { profile }) { const issues = collectIssues(goal); if (!issues.length && !collectGaps(goal).length) return []; const order = { conflicting: 0, missing: 1, requires_review: 2, inferred: 3 }; // Пробелы, уже описанные человеческим языком в блоке «Чего не хватает», здесь не повторяем. const gapWords = collectGaps(goal).map((gap) => gap.id === "goal_owner" ? "владел" : gap.id); const shown = issues .filter((issue) => profile === "audit" || issue.status !== "inferred") .filter((issue) => profile === "audit" || !gapWords.some((word) => issue.subject.toLowerCase().includes(word))) .sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9)); const gaps = collectGaps(goal); if (!shown.length && !gaps.length) return []; const counts = shown.reduce((acc, issue) => ({ ...acc, [issue.status]: (acc[issue.status] ?? 0) + 1 }), {}); const summary = Object.entries(counts) .map(([status, count]) => `${STATUS_META[status]?.label ?? status}: ${count}`).join(", "); // Строки с конфликтом подсвечиваются фоном ячейки — рендерер поддерживает // фиксированный набор цветов (#FFBDAD, #ABF5D1, #F4F5F7, #ffffff). const CELL_BG = { conflicting: "#FFBDAD", missing: "#F4F5F7", requires_review: "#ffffff", inferred: "#ffffff" }; const rows = shown.map((issue) => [ { inlines: provenance({ status: issue.status }, { profile, always: true }), bg: CELL_BG[issue.status] ?? "#ffffff" }, issue.section, truncate(issue.subject), truncate(issue.notes, 120) ]); const HEAD = profile === "audit" ? 12 : 3; const inferredRows = profile === "audit" ? rows.filter((row, index) => shown[index].status === "inferred") : []; const head = rows.slice(0, HEAD); const rest = rows.slice(HEAD); const columns = ["Статус", "Раздел", "Что именно", "Комментарий"]; const widths = [220, 280, 600, 400]; const blocks = []; // Пробелы и «что проверить» живут в одной панели: два предупреждающих блока подряд // соревнуются за внимание и дробят одну и ту же мысль. if (gaps.length) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Чего не хватает" }] }); blocks.push(table( ["Чего не хватает", "Почему это важно", "Что делать"], gaps.map((gap) => [gap.what, gap.why, gap.action]), [420, 620, 460] )); } if (shown.length) { if (gaps.length) blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Что проверить" }] }); blocks.push({ type: "paragraph", inlines: [{ type: "text", text: `Полей, требующих внимания: ${shown.length} (${summary}). Ниже — самое важное; остальное раскрывается по клику.` }] }); blocks.push(table(columns, head, widths)); } if (shown.length && rest.length) { blocks.push({ type: "expand", title: `Ещё ${rest.length}`, open: false, blocks: [table(columns, rest, widths)] }); } return [{ type: "panel", panelType: "warning", title: "Требует решения", showtitle: true, showicon: true, blocks }]; } function meaningBlocks(goal, options) { const meaning = goal.goal_meaning ?? {}; const blocks = [heading(2, "Смысл цели")]; const sections = [ ["Описание", meaning.description], ["Зачем нужна", meaning.business_reason], ["Что даёт", meaning.expected_effect], ["Целевое состояние", meaning.target_state] ]; // Каждый пункт смысла — один-два абзаца, отдельный заголовок для них избыточен: // подпись в самом абзаце читается так же и не засоряет оглавление. for (const [title, field] of sections) { const body = textFieldBlocks(field, { ...options, showEmpty: true, label: title }); if (!body.length) continue; blocks.push(...body); } return blocks.length > 1 ? blocks : []; } function timelineBlocks(goal, options) { const timeline = goal.timeline ?? {}; const blocks = []; // Читателю имя метрики говорит больше, чем её идентификатор. const metricTitles = new Map((goal.metrics?.primary_metrics ?? []).map((metric) => [metric.metric_id, plainText(metric.title)])); const metricCell = (id) => { if (!id) return ""; if (options.profile === "audit") return { inlines: [{ type: "code", text: id }] }; return truncate(metricTitles.get(id) ?? id, 60); }; // Целевые значения метрик по периодам живут в той же таблице сроков: отдельный раздел // для них повторял бы и период, и цель. const targets = [timeline.annual_target, ...(timeline.quarterly_targets ?? []), ...(goal.metrics?.target_values ?? [])].filter(Boolean); if (targets.length) { blocks.push(heading(2, "Сроки и закрытие")); blocks.push(table( ["Период", "Цель периода", "Значение", "Метрика", "Статус"], targets.map((target) => [ target.label ?? target.period_id, `${target.target_text}${target.scope ? ` (${target.scope})` : ""}${target.notes ? ` — ${target.notes}` : ""}`, target.target_value ?? "", metricCell(target.metric_id), statusCell(target.state, options) ]), [220, 620, 180, 260, 220] )); } if (isFilled(timeline.milestones)) { blocks.push(heading(3, "Вехи")); const milestones = [...timeline.milestones ?? []].sort((a, b) => String(a.due_date ?? "9999").localeCompare(String(b.due_date ?? "9999"))); blocks.push(table( ["Срок", "Веха", "Период", "Детали", "Статус"], milestones.map((milestone) => [ milestone.due_date ?? "", milestone.title, options.profile === "audit" ? (milestone.period_id ?? "") : "", milestone.details ?? "", statusCell(milestone.state, options) ]), [180, 420, 180, 500, 220] )); } return blocks; } function metricOwnerName(metric) { const owner = (metric.calculation_playbook?.contacts ?? []).find((contact) => contact.contact_type === "metric_owner"); return owner ? [owner.name, owner.email].filter(Boolean).join(", ") : ""; } // Одна цель может нести десятки метрик, поэтому раздел строится в два уровня: // сводная таблица по всем метрикам + детали каждой. При трёх и более метриках детали // уходят в сворачиваемый expand, иначе страница превращается в простыню. function metricsSummaryTable(metrics, options) { const withId = options.profile === "audit"; return table( withId ? ["Метрика", "ID", "Целевое значение", "Владелец метрики", "Периодичность", "Статус"] : ["Метрика", "Целевое значение", "Владелец метрики", "Периодичность", "Статус"], metrics.map((metric) => { const row = [metric.title]; if (withId) row.push({ inlines: [{ type: "code", text: metric.metric_id }] }); row.push( metric.typed_value ? typedValueText(metric.typed_value) : "", metricOwnerName(metric), metric.calculation_playbook?.refresh_cadence?.value ?? "", statusCell(metric.state, options) ); return row; }), withId ? [420, 200, 260, 300, 260, 160] : [480, 280, 340, 300, 100] ); } function metricsBlocks(goal, options) { const metrics = goal.metrics ?? {}; if (!isFilled(metrics.primary_metrics)) return []; const blocks = [heading(2, "Метрики и расчёт")]; // Сворачиваем не только по количеству метрик: одна метрика с десятком исключений // и полным playbook занимает столько же места, сколько три простые. const playbookWeight = (metric) => { const playbook = metric.calculation_playbook ?? {}; return ["data_sources", "dashboards", "access_instructions", "manual_steps", "filters", "dimension_mappings", "exclusions", "contacts"] .reduce((sum, key) => sum + (playbook[key]?.length ?? 0), 0) + (metric.calculation_notes?.length ?? 0); }; const collapse = (metrics.primary_metrics ?? []).length >= 3 || ((metrics.primary_metrics ?? []).length > 1 && (metrics.primary_metrics ?? []).some((metric) => playbookWeight(metric) > 8)); if ((metrics.primary_metrics ?? []).length > 1) { blocks.push(metricsSummaryTable(metrics.primary_metrics, options)); } for (const metric of metrics.primary_metrics) { const detail = []; const push = (...items) => detail.push(...items); if (!collapse) blocks.push(heading(3, plainText(metric.title))); if (options.profile === "audit") push({ type: "paragraph", inlines: [{ type: "text", text: "Идентификатор метрики: " }, { type: "code", text: metric.metric_id }, ...(provenance(metric.state, options).length ? [{ type: "text", text: " " }, ...provenance(metric.state, options)] : [])] }); push(...textFieldBlocks(metric.description, options)); const formulaRows = []; if (metric.numerator) formulaRows.push(["Числитель", metric.numerator.value, statusCell(metric.numerator.state, options)]); if (metric.denominator) formulaRows.push(["Знаменатель", metric.denominator.value, statusCell(metric.denominator.state, options)]); if (metric.typed_value) { formulaRows.push([ "Целевое значение", `${typedValueText(metric.typed_value)}${metric.typed_value.notes ? ` — ${metric.typed_value.notes}` : ""}`, "" ]); } if (formulaRows.length) push(table(["Элемент", "Значение", "Статус"], formulaRows, [300, 900, 300])); if (isFilled(metric.calculation_notes)) { push({ type: "paragraph", inlines: [{ type: "strong", text: "Примечания к расчёту" }] }); push({ type: "orderedList", items: metric.calculation_notes.map((note) => ({ inlines: [{ type: "text", text: note.value }, ...(provenance(note.state, options).length ? [{ type: "text", text: " " }, ...provenance(note.state, options)] : [])] })) }); } const playbook = metric.calculation_playbook; if (playbook) { const tabs = []; const dataBlocks = []; for (const source of playbook.data_sources ?? []) dataBlocks.push(...textFieldBlocks(source, options)); // Кнопка дашборда не дублируется здесь: все ссылки собраны в разделе «Куда идти». for (const dashboard of playbook.dashboards ?? []) { dataBlocks.push({ type: "paragraph", inlines: [ { type: "strong", text: "Дашборд: " }, { type: "text", text: dashboard.label ?? dashboard.url } ] }); } // access_instructions не дублируются кнопкой здесь — все ссылки собраны в разделе «Куда идти». if (dataBlocks.length) tabs.push({ label: "Источники данных", blocks: dataBlocks }); if (isFilled(playbook.manual_steps)) { tabs.push({ label: "Шаги расчёта", blocks: [{ type: "orderedList", items: playbook.manual_steps.map((step) => ({ text: [step.title, step.details ? `— ${step.details}` : "", step.role ? `(${step.role})` : ""].filter(Boolean).join(" ") })) }] }); } const filterRows = [ ...(playbook.filters ?? []).map((filter) => ["Фильтр", filter.title, filter.details ?? "", statusCell(filter.state, options)]), ...(playbook.exclusions ?? []).map((exclusion) => ["Исключение", exclusion.title, exclusion.details ?? "", statusCell(exclusion.state, options)]) ]; if (filterRows.length) { tabs.push({ label: "Фильтры и исключения", blocks: [table(["Тип", "Название", "Детали", "Статус"], filterRows, [240, 420, 620, 220])] }); } if (isFilled(playbook.dimension_mappings)) { tabs.push({ label: "Сопоставление размерностей", blocks: [table( ["Сопоставление", "Размерность", "Значение источника", "Соответствует", "Область", "Статус"], playbook.dimension_mappings.map((mapping) => [ `${plainText(mapping.label)}${mapping.notes ? ` — ${mapping.notes}` : ""}`, plainText(mapping.dimension), plainText(mapping.source_value), (mapping.mapped_to_values ?? []).join(", "), mapping.scope ?? "", statusCell(mapping.state, options) ]), [320, 220, 260, 300, 240, 160] )] }); } const regimeBlocks = []; for (const contact of playbook.contacts ?? []) { regimeBlocks.push({ type: "paragraph", inlines: [ { type: "strong", text: `${CONTACT_TYPE_LABEL[contact.contact_type] ?? contact.contact_type}: ` }, { type: "text", text: [contact.name, contact.role_details, contact.unit, contact.email, contact.phone].filter(Boolean).join(", ") } ] }); } regimeBlocks.push(...textFieldBlocks(playbook.refresh_cadence, { ...options, label: "Периодичность" })); regimeBlocks.push(...textFieldBlocks(playbook.calculation_period_cutoff, { ...options, label: "Отсечка периода" })); if (regimeBlocks.length) tabs.push({ label: "Регламент и контакты", blocks: regimeBlocks }); if (tabs.length > 1) { push({ type: "paragraph", inlines: [{ type: "strong", text: "Как считать" }] }); push({ type: "tabs", tabs }); } else if (tabs.length === 1) { // Одна вкладка — это не альтернативные представления, а просто раздел. push({ type: "paragraph", inlines: [{ type: "strong", text: `Как считать · ${tabs[0].label}` }] }); push(...tabs[0].blocks); } } if (collapse) { blocks.push({ type: "expand", title: plainText(metric.title), open: false, blocks: compact(detail) }); } else { blocks.push(...detail); } } if (false && isFilled(metrics.target_values)) { blocks.push(heading(3, "Дополнительные целевые значения")); blocks.push(table( ["Период", "Цель", "Значение", "Метрика", "Статус"], (metrics.target_values ?? []).map((target) => [ target.label ?? target.period_id, target.target_text, target.target_value ?? "", options.profile === "audit" ? { inlines: [{ type: "code", text: target.metric_id ?? "" }] } : truncate((metrics.primary_metrics ?? []).find((m) => m.metric_id === target.metric_id)?.title ?? target.metric_id ?? "", 60), statusCell(target.state, options) ]), [240, 620, 180, 240, 220] )); } if (isFilled(metrics.baseline_observations)) { blocks.push(heading(3, "Наблюдения и базовая линия")); blocks.push(table( ["Наблюдение", "Дата", "Значения", "Разрезы", "Статус"], (metrics.baseline_observations ?? []).map((observation) => [ observation.label, observation.observed_at ?? "", observationText(observation.values), observationText(observation.dimensions), statusCell(observation.state, options) ]), [400, 200, 400, 280, 220] )); } if (isFilled(metrics.indicator_states)) { const indicatorBlocks = metrics.indicator_states.flatMap((indicator) => textFieldBlocks(indicator, { ...options, showEmpty: true })); if (indicatorBlocks.length) blocks.push(heading(3, "Индикаторы"), ...indicatorBlocks); } return blocks; } // Реестр целевых сущностей рисуется одинаково и внутри вкладки «Реестры», и в линейной вёрстке. function targetSetBlocks(set, options) { const out = []; const meta = [ `Вид набора: ${SET_KIND_LABEL[set.set_kind] ?? set.set_kind}`, set.dimensions ? `разрезы: ${keyValueText(set.dimensions, options)}` : "", set.source_list_ref ? `источник: ${set.source_list_ref}` : "" ].filter(Boolean).join("; "); out.push({ type: "paragraph", inlines: [{ type: "strong", text: `${set.title}. ` }, { type: "text", text: meta }] }); out.push(...textFieldBlocks(set.description, options)); const declared = set.declared_count ?? null; const extracted = set.extracted_count ?? (set.items?.length ?? 0); if (set.extraction_status && set.extraction_status !== "complete") { out.push({ type: "panel", panelType: "warning", title: EXTRACTION_STATUS_LABEL[set.extraction_status] ?? set.extraction_status, blocks: [{ type: "paragraph", inlines: [{ type: "text", text: `${declared !== null ? `Заявлено строк: ${declared}, извлечено: ${extracted}. ` : ""}${set.extraction_notes ?? ""}` }] }] }); } if (isFilled(set.items)) { const itemRows = (set.items ?? []).map((item) => [ item.raw_name, item.ci_code ? { inlines: [{ type: "code", text: item.ci_code }] } : "", item.normalized_name ?? "", MATCH_METHOD_LABEL[item.match_method] ?? item.match_method, keyValueText(item.attributes, options), statusCell(item.state, options) ]); const itemsTable = table(["Объект", "CI", "Нормализовано", "Сопоставление", "Атрибуты", "Статус"], itemRows, [340, 200, 300, 240, 260, 160]); // Реестр на два десятка строк не должен занимать половину страницы. out.push(itemRows.length > 8 ? { type: "expand", title: `Состав набора (${itemRows.length})`, open: false, blocks: compact([itemsTable]) } : itemsTable); } return compact(out); } function applicabilityBlocks(goal, options) { const applicability = goal.applicability ?? {}; const blocks = [heading(2, "Применимость")]; blocks.push({ type: "paragraph", inlines: [ { type: "strong", text: "Типы объектов: " }, { type: "text", text: entityTypeList(applicability.target_entity_types) } ] }); // Включение, исключения и реестры — это ответы на один вопрос «попадаю ли я под цель», // но с разных сторон. От трёх наполненных групп они читаются вкладками, а не одной // таблицей с колонкой «Тип», которая повторяется в каждой строке. const ruleTable = (rules) => table( ["Правило", "Условие", "Объекты", "Статус"], (rules ?? []).map((rule) => [ rule.title, [rule.description?.value, `Условие: ${selectorText(rule.selector)}`, rule.description?.state?.notes ? `(${rule.description.state.notes})` : ""] .filter(Boolean).join(" "), entityTypeList(rule.target_entity_types), statusCell(rule.state, options) ]), [340, 700, 260, 200] ); const criteriaBlocks = isFilled(applicability.selection_criteria) ? compact([table( ["Критерий", "Детали", "Статус"], (applicability.selection_criteria ?? []).map((criterion) => [criterion.title, criterion.details ?? "", statusCell(criterion.state, options)]), [420, 860, 220] )]) : []; const setBlocks = compact((applicability.explicit_target_sets ?? []).flatMap((set) => targetSetBlocks(set, options))); const uncertainBlocks = isFilled(applicability.uncertain_cases) ? compact([table( ["Формулировка", "Почему неоднозначно", "Кандидаты", "Статус"], (applicability.uncertain_cases ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [400, 520, 380, 200] )]) : []; const groups = [ ["Кому устанавливается", compact([ruleTable(applicability.inclusion_rules)])], ["Исключения", compact([ruleTable(applicability.exclusion_rules)])], ["Критерии отбора", criteriaBlocks], ["Реестры", setBlocks], ["Спорные случаи", uncertainBlocks] ].filter(([, body]) => body.length); const filledGroups = groups.filter(([, body]) => compact(body).length); if (filledGroups.length >= 3) { blocks.push({ type: "tabs", tabs: filledGroups.map(([label, body]) => ({ label, blocks: compact(body) })) }); return compact(blocks); } const ruleRows = (rules, kind) => (rules ?? []).map((rule) => [ kind, rule.title, [rule.description?.value, `Условие: ${selectorText(rule.selector)}`, rule.description?.state?.notes ? `(${rule.description.state.notes})` : ""] .filter(Boolean).join(" "), entityTypeList(rule.target_entity_types), statusCell(rule.state, options) ]); const rules = [...ruleRows(applicability.inclusion_rules, "Включение"), ...ruleRows(applicability.exclusion_rules, "Исключение")]; if (rules.length) blocks.push(table(["Тип", "Правило", "Условие", "Объекты", "Статус"], rules, [220, 320, 620, 180, 160])); if (isFilled(applicability.selection_criteria)) { blocks.push(heading(3, "Критерии отбора")); blocks.push(table( ["Критерий", "Детали", "Статус"], (applicability.selection_criteria ?? []).map((criterion) => [criterion.title, criterion.details ?? "", statusCell(criterion.state, options)]), [420, 860, 220] )); } for (const set of applicability.explicit_target_sets ?? []) { blocks.push(heading(3, `Реестр: ${set.title}`), ...targetSetBlocks(set, options)); } if (isFilled(applicability.temporary_exclusions)) { blocks.push(heading(3, "Временные исключения")); blocks.push(table( ["Объект", "CI", "Тип", "Статус"], (applicability.temporary_exclusions ?? []).map((mention) => [ mention.normalized_name && mention.normalized_name !== mention.raw_text ? `${mention.normalized_name} (в источнике: ${mention.raw_text})` : mention.raw_text, mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", ENTITY_TYPE_LABEL[mention.entity_type] ?? mention.entity_type ?? "", statusCell(mention.state, options) ]), [500, 250, 250, 500] )); } if (isFilled(applicability.uncertain_cases)) { blocks.push({ type: "panel", panelType: "warning", title: "Неоднозначные случаи применимости", blocks: [table( ["Формулировка", "Почему неоднозначно", "Кандидаты", "Статус"], (applicability.uncertain_cases ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [400, 520, 380, 200] )] }); } return blocks; } function standardsBlocks(goal, options) { const standards = goal.standards_and_requirements ?? {}; const groups = [ ["Стандарты", standards.standards], ["Требования", standards.requirements], ["Процессы", standards.processes] ].filter(([, items]) => isFilled(items)); if (!groups.length) return []; const blocks = [heading(2, "Стандарты, требования и процессы")]; // Вкладки ради двух таблиц по одной строке только прячут содержимое: до трёх групп // выводим их подряд обычными подзаголовками. if (groups.length < 3) { for (const [label, items] of groups) { blocks.push(heading(3, label)); blocks.push(table( ["Название", "Стандарт", "Содержание", "Статус"], items.map((item) => [ plainText(item.title), item.standard_name ?? "", `${item.requirement_text?.value ?? ""}${item.requirement_text?.state?.notes ? ` (${item.requirement_text.state.notes})` : ""}`, statusCell(item.requirement_text?.state?.status && item.requirement_text.state.status !== "confirmed" ? item.requirement_text.state : item.state, options) ]), [340, 240, 700, 220] )); } return blocks; } blocks.push({ type: "tabs", tabs: groups.map(([label, items]) => ({ label, blocks: [ table( ["Название", "Стандарт", "Содержание", "Статус"], items.map((item) => [ plainText(item.title), item.standard_name ?? "", `${item.requirement_text?.value ?? ""}${item.requirement_text?.state?.notes ? ` (${item.requirement_text.state.notes})` : ""}`, statusCell(item.requirement_text?.state?.status && item.requirement_text.state.status !== "confirmed" ? item.requirement_text.state : item.state, options) ]), [340, 240, 700, 220] ), // ссылки стандартов и требований собраны в разделе «Куда идти» ] })) }); return blocks; } // Действия — таблица, а не taskList: JSON не хранит выполнение, а чек-лист с вечно снятыми // галочками выглядит как заброшенный трекер. function actionsTable(actions, options) { return table( ["Действие", "Кто делает", "Детали", "Статус"], actions.map((action) => [ action.title, action.role ?? "", action.details ?? "", statusCell(action.state, options) ]), [420, 280, 620, 180] ); } function executionBlocks(goal, options) { const execution = goal.execution ?? {}; const groups = [ ["Обязательные", execution.required_actions], ["Рекомендуемые", execution.recommended_actions], ...(execution.role_actions ?? []).map((roleActions) => [`Роль: ${roleActions.role}`, roleActions.actions]) ].filter(([, actions]) => isFilled(actions)); if (!groups.length) return []; // Обязательные, рекомендуемые и ролевые действия — параллельные взгляды на один список. // От трёх групп вкладки читаются лучше, чем три подряд идущие таблицы; меньше — заголовками. if (groups.length >= 3) { return [ heading(2, "Что нужно сделать"), { type: "tabs", tabs: groups.map(([label, actions]) => ({ label, blocks: compact([actionsTable(actions, options)]) })) } ]; } const blocks = [heading(2, "Что нужно сделать")]; for (const [label, actions] of groups) { blocks.push(heading(3, `${label} действия`), actionsTable(actions, options)); } return compact(blocks); } // Заголовки действий, уже перечисленных в «Что нужно сделать»: чтобы не повторять их // третий раз внутри блокеров. function plannedActionTitles(goal) { const execution = goal.execution ?? {}; const all = [ ...(execution.required_actions ?? []), ...(execution.recommended_actions ?? []), ...(execution.role_actions ?? []).flatMap((role) => role.actions ?? []) ]; return new Set(all.map((action) => String(action.title ?? "").trim().toLowerCase())); } function implementationBlocks(goal, options) { const details = goal.implementation_details ?? {}; if (!isFilled(details.areas) && !isFilled(details.notes)) return []; // По контракту expand — для справочной и второстепенной информации. Детали реализации // нужны исполнителю в момент работы, а не при чтении страницы сверху вниз. const inner = []; for (const area of details.areas ?? []) { inner.push({ type: "paragraph", inlines: [{ type: "strong", text: area.area }] }); inner.push(actionsTable(area.items ?? [], options)); } for (const note of details.notes ?? []) inner.push(...textFieldBlocks(note, options)); const body = compact(inner); if (!body.length) return []; const count = (details.areas ?? []).reduce((sum, area) => sum + (area.items?.length ?? 0), 0); return [ heading(2, "Детали реализации"), { type: "expand", title: count ? `Как это делается (${count})` : "Как это делается", open: false, blocks: body } ]; } function completionBlocks(goal, options) { const completion = goal.completion ?? {}; const has = ["dod", "metric_formula", "acceptance_notes"].some((key) => completion[key]) || isFilled(completion.closing_artifacts) || isFilled(completion.evidence_examples); if (!has) return []; // Закрытие — это продолжение разговора о сроках: когда фиксируем, по каким признакам // считаем достигнутым. Отдельный раздел верхнего уровня для двух абзацев избыточен. const blocks = [heading(3, "Закрытие цели")]; if (completion.dod) blocks.push(...textFieldBlocks(completion.dod, { ...options, label: "Definition of Done" })); // Пустой code-блок выглядит как ошибка рендера, поэтому формула выводится только когда она есть; // её отсутствие уже объяснено в блоке «Чего не хватает». if (isFilled(completion.metric_formula?.value)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Формула закрытия" }] }); blocks.push({ type: "code", code: completion.metric_formula.value }); const prov = provenance(completion.metric_formula.state, options); if (prov.length) blocks.push({ type: "paragraph", inlines: prov }); } if (completion.acceptance_notes) blocks.push(...textFieldBlocks(completion.acceptance_notes, { ...options, label: "Приёмка" })); const artifacts = [ ...(completion.closing_artifacts ?? []).map((artifact) => ["Закрывающий артефакт", artifact]), ...(completion.evidence_examples ?? []).map((artifact) => ["Пример подтверждения", artifact]) ]; if (artifacts.length) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Артефакты и подтверждения" }] }); blocks.push(table( ["Вид", "Название", "Детали", "Статус"], artifacts.map(([kind, artifact]) => [kind, artifact.title, artifact.details ?? "", statusCell(artifact.state, options)]), [320, 500, 480, 200] )); } return blocks; } function responsibilityBlocks(goal, options) { const responsibility = goal.responsibility ?? {}; if (!isFilled(responsibility.contacts) && !isFilled(responsibility.open_questions)) return []; const blocks = [heading(2, "Кто отвечает и куда обращаться")]; if (isFilled(responsibility.contacts)) { blocks.push(table( options.profile === "audit" ? ["Роль", "Кто", "Тип", "Подразделение", "Контакт", "Статус"] : ["Роль", "Кто", "Подразделение", "Контакт", "Статус"], (responsibility.contacts ?? []).map((contact) => [ CONTACT_TYPE_LABEL[contact.contact_type] ?? contact.contact_type, `${contact.name}${contact.role_details ? ` — ${contact.role_details}` : ""}`, ...(options.profile === "audit" ? [ASSIGNEE_KIND_LABEL[contact.assignee_kind] ?? contact.assignee_kind] : []), contact.unit ?? "", [contact.email, contact.phone].filter(Boolean).join(", "), statusCell(contact.state, options) ]), options.profile === "audit" ? [240, 420, 180, 280, 260, 120] : [260, 480, 320, 320, 120] )); } const questions = [...(responsibility.open_questions ?? []), ...(goal.knowledge?.open_questions ?? [])]; if (questions.length) { blocks.push({ type: "panel", panelType: "note", title: "Открытые вопросы", blocks: [table( ["Вопрос", "Почему открыт", "Статус"], questions.map((question) => [question.question, question.reason, statusCell(question.state, options)]), [600, 680, 220] )] }); } return blocks; } function obstaclesBlocks(goal, options) { if (!isFilled(goal.obstacles)) return []; const blocks = [heading(2, "Блокеры и риски")]; const planned = plannedActionTitles(goal); const isMajor = (obstacle) => obstacle.severity === "blocker" || obstacle.severity === "major" || !obstacle.severity; const minor = []; const panelType = (severity) => (severity === "blocker" || severity === "major" ? "warning" : severity === "info" ? "info" : "note"); for (const obstacle of goal.obstacles) { const inner = []; // В шапке блокера показываем значимость самого риска. Провенанс («подтверждено») // здесь читается как «всё хорошо», поэтому он остаётся только в аудиторском профиле. inner.push({ type: "paragraph", inlines: [ { type: "text", text: `Вид: ${OBSTACLE_KIND_LABEL[obstacle.kind] ?? obstacle.kind}; область: ${SCOPE_LABEL[obstacle.scope] ?? obstacle.scope}${obstacle.severity ? `; значимость: ${SEVERITY_LABEL[obstacle.severity] ?? obstacle.severity}` : ""}` }, ...(options.profile === "audit" ? [{ type: "text", text: " " }, ...provenance(obstacle.state, { ...options, always: true })] : []) ] }); inner.push(...textFieldBlocks(obstacle.description, obstacle.description?.state?.status === obstacle.state?.status ? { ...options, profile: "public" } : options)); if (obstacle.impact) { const impactRows = []; if (obstacle.impact.risk) impactRows.push(["Риск", obstacle.impact.risk]); if (obstacle.impact.potential_reward) impactRows.push(["Выигрыш от решения", obstacle.impact.potential_reward]); if (impactRows.length) inner.push(table(["Аспект", "Описание"], impactRows, [400, 1100])); } if (isFilled(obstacle.mitigation)) { const fresh = obstacle.mitigation.filter((action) => !planned.has(String(action.title ?? "").trim().toLowerCase())); inner.push({ type: "paragraph", inlines: [{ type: "strong", text: "Что делать" }] }); if (fresh.length) { inner.push(actionsTable(fresh, options)); } if (fresh.length !== obstacle.mitigation.length) { inner.push({ type: "paragraph", inlines: [{ type: "em", text: "Остальные шаги перечислены в разделе «Что нужно сделать»." }] }); } } if (options.profile === "audit" && isFilled(obstacle.affected_field_paths)) { inner.push({ type: "paragraph", inlines: [ { type: "text", text: "Затронутые поля: " }, ...obstacle.affected_field_paths.flatMap((path, index) => (index ? [{ type: "text", text: ", " }] : []).concat([{ type: "code", text: path }])) ] }); } const panel = { type: "panel", panelType: panelType(obstacle.severity), title: obstacle.title, showtitle: true, showicon: true, blocks: compact(inner) }; if (isMajor(obstacle)) blocks.push(panel); else minor.push(panel); } // Стена из панелей утомляет: незначительное прячем, но не выбрасываем. if (minor.length) { blocks.push({ type: "expand", title: `Незначительные риски (${minor.length})`, open: false, blocks: minor }); } return blocks; } function relationsBlocks(goal, options) { if (!isFilled(goal.goal_relations)) return []; return [ heading(2, "Связи с другими целями"), table( ["Связь", "Цель", "Описание", "Статус"], (goal.goal_relations ?? []).map((relation) => [ RELATION_LABEL[relation.relation_type] ?? relation.relation_type, `${relation.object_goal_title ?? ""} (${relation.object_goal_id})`, relation.description?.value ?? "", statusCell(relation.state, options) ]), [240, 460, 580, 220] ) ]; } function outcomesBlocks(goal, options) { if (!isFilled(goal.outcomes)) return []; return [ heading(2, "Результаты"), table( ["Результат", "Период", "Дата", "Значение", "Метрика", "Статус"], (goal.outcomes ?? []).map((outcome) => [ `${outcome.title}${outcome.description?.value ? ` — ${outcome.description.value}` : ""}`, outcome.period_id ?? "", outcome.achieved_at ?? "", outcome.typed_value ? typedValueText(outcome.typed_value) : formatScalar(outcome.achieved_value), options.profile === "audit" ? (outcome.linked_metric_id ? { inlines: [{ type: "code", text: outcome.linked_metric_id }] } : "") : truncate((goal.metrics?.primary_metrics ?? []).find((m) => m.metric_id === outcome.linked_metric_id)?.title ?? "", 50), statusCell(outcome.state, options) ]), [520, 180, 180, 240, 240, 140] ) ]; } // Внутренние идентификаторы источников нужны аудитору; читателю достаточно раздела «Куда идти». function sourcesBlocks(goal, options) { if (options.profile !== "audit") return []; const blocks = [heading(2, "Источники")]; blocks.push({ type: "paragraph", inlines: [{ type: "text", text: "Сегменты исходного текста, из которых собрана страница. Ссылки на внешние документы — в разделе «Куда идти»." }] }); blocks.push(table( ["ID", "Тип", "Название", "Где лежит", "Собрано"], (goal.source_refs ?? []).map((source) => [ { inlines: [{ type: "code", text: source.id }] }, SOURCE_TYPE_LABEL[source.type] ?? source.type, source.title, [source.path, source.url, source.locator].filter(Boolean).join(" · "), source.collected_at ?? "" ]), [180, 180, 480, 460, 200] )); return blocks; } function mentionsBlocks(goal, options) { const mentions = goal.entity_mentions ?? {}; if (!isFilled(mentions.raw_mentions) && !isFilled(mentions.unresolved_mentions) && !isFilled(mentions.normalization_notes)) return []; const blocks = []; if (isFilled(mentions.raw_mentions)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Распознанные" }] }); blocks.push(table( ["Упоминание", "Нормализовано", "Тип", "CI", "Метод", "Статус"], (mentions.raw_mentions ?? []).map((mention) => [ mention.raw_text, mention.normalized_name ?? "", ENTITY_TYPE_LABEL[mention.entity_type] ?? "", mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", MATCH_METHOD_LABEL[mention.match_method] ?? mention.match_method, statusCell(mention.state, options) ]), [300, 300, 200, 200, 300, 200] )); } if (isFilled(mentions.unresolved_mentions)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Неразрешённые" }] }); blocks.push(table( ["Упоминание", "Причина", "Кандидаты", "Статус"], (mentions.unresolved_mentions ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [340, 460, 500, 200] )); } if (isFilled(mentions.normalization_notes)) { blocks.push({ type: "paragraph", inlines: [{ type: "strong", text: "Заметки нормализации" }] }); blocks.push({ type: "orderedList", items: (mentions.normalization_notes ?? []).map((note) => ({ text: note })) }); } return compact(blocks); } function auditBlocksLegacy(goal, options) { if (options.profile !== "audit") return []; const blocks = []; if (isFilled(goal.source_coverage)) { blocks.push(heading(2, "Аудит: покрытие источников")); blocks.push(table( ["Источник", "Покрытие", "Использован для", "Примечание", "Статус"], (goal.source_coverage ?? []).map((coverage) => [ { inlines: [{ type: "code", text: coverage.source_ref_id }] }, COVERAGE_LABEL[coverage.coverage_status] ?? coverage.coverage_status, (coverage.used_for ?? []).join(", "), coverage.notes ?? "", statusCell(coverage.state, options) ]), [200, 300, 400, 400, 200] )); } const mentions = goal.entity_mentions ?? {}; if (isFilled(mentions.raw_mentions) || isFilled(mentions.unresolved_mentions) || isFilled(mentions.normalization_notes)) { blocks.push(heading(2, "Аудит: упоминания сущностей")); const tabs = []; if (isFilled(mentions.raw_mentions)) { tabs.push({ label: "Распознанные", blocks: [table( ["Упоминание", "Нормализовано", "Тип", "CI", "Метод", "Статус"], (mentions.raw_mentions ?? []).map((mention) => [ mention.raw_text, mention.normalized_name ?? "", ENTITY_TYPE_LABEL[mention.entity_type] ?? "", mention.ci_code ? { inlines: [{ type: "code", text: mention.ci_code }] } : "", MATCH_METHOD_LABEL[mention.match_method] ?? mention.match_method, statusCell(mention.state, options) ]), [300, 300, 200, 200, 300, 200] )] }); } if (isFilled(mentions.unresolved_mentions)) { tabs.push({ label: "Неразрешённые", blocks: [table( ["Упоминание", "Причина", "Кандидаты", "Статус"], (mentions.unresolved_mentions ?? []).map((item) => [ item.raw_text, item.reason, (item.candidate_entity_ids ?? []).join(", "), statusCell(item.state, options) ]), [340, 460, 500, 200] )] }); } if (isFilled(mentions.normalization_notes)) { tabs.push({ label: "Заметки нормализации", blocks: [{ type: "orderedList", items: (mentions.normalization_notes ?? []).map((note) => ({ text: note })) }] }); } blocks.push({ type: "tabs", tabs }); } if (isFilled(goal.knowledge?.source_quotes)) { blocks.push(heading(2, "Аудит: цитаты из источников")); blocks.push({ type: "expand", title: `Цитаты (${(goal.knowledge?.source_quotes ?? []).length})`, open: false, blocks: [table( ["ID", "Источник", "Цитата", "Трактовка", "Поле"], goal.knowledge.source_quotes.map((quote) => [ { inlines: [{ type: "code", text: quote.quote_id }] }, { inlines: [{ type: "code", text: quote.source_ref_id }] }, quote.quote, quote.interpretation, quote.field_path ? { inlines: [{ type: "code", text: quote.field_path }] } : "" ]), [160, 160, 520, 420, 240] )] }); } return blocks; } /* ------------------------------------------------------------------- main */ // table() может свернуться в абзац или вовсе исчезнуть (если строк нет), // поэтому финальная сборка отбрасывает пустые места. function compact(blocks) { return blocks.filter(Boolean); } // Аудиторский хвост — одна секция с вкладками, а не четыре отдельных раздела: // паспорт, источники с покрытием, упоминания и цитаты читают по одному, а не подряд. function auditTailBlocks(goal, options) { if (options.profile !== "audit") return []; const tabs = []; const passport = passportBlocks(goal, options).filter((block) => block.type !== "heading"); if (passport.length) tabs.push({ label: "Паспорт", blocks: compact(passport) }); const sources = sourcesBlocks(goal, options).filter((block) => block.type !== "heading"); const coverage = coverageTable(goal, options); if (sources.length || coverage) tabs.push({ label: "Источники и покрытие", blocks: compact([...sources, coverage]) }); const mentions = mentionsBlocks(goal, options); if (mentions.length) tabs.push({ label: "Упоминания сущностей", blocks: compact(mentions) }); const quotes = quoteBlocks(goal, options); if (quotes.length) tabs.push({ label: "Цитаты", blocks: compact(quotes) }); if (!tabs.length) return []; return [heading(2, "Аудиторские данные"), { type: "tabs", tabs }]; } function coverageTable(goal, options) { if (!isFilled(goal.source_coverage)) return null; return table( ["Источник", "Покрытие", "Использован для", "Примечание", "Статус"], (goal.source_coverage ?? []).map((coverage) => [ { inlines: [{ type: "code", text: coverage.source_ref_id }] }, COVERAGE_LABEL[coverage.coverage_status] ?? coverage.coverage_status, (coverage.used_for ?? []).join(", "), coverage.notes ?? "", statusCell(coverage.state, options) ]), [200, 300, 400, 400, 200] ); } // Цитата — это текст, а не строка таблицы: в пятиколоночной ячейке она превращается // в столбик из двух слов высотой в пол-экрана. function quoteBlocks(goal, options) { const quotes = goal.knowledge?.source_quotes ?? []; if (!quotes.length) return []; const blocks = []; for (const quote of quotes) { blocks.push({ type: "paragraph", inlines: [ { type: "strong", text: `${quote.quote_id}` }, { type: "text", text: " · источник " }, { type: "code", text: quote.source_ref_id }, ...(quote.field_path ? [{ type: "text", text: " · поле " }, { type: "code", text: quote.field_path }] : []) ] }); blocks.push({ type: "paragraph", inlines: [{ type: "em", text: `«${quote.quote}»` }] }); blocks.push({ type: "paragraph", inlines: [{ type: "text", text: quote.interpretation }] }); } return [{ type: "expand", title: `Цитаты из источников (${quotes.length})`, open: false, blocks }]; } export function goalToLayout(goal, { profile = "public" } = {}) { const options = { profile }; const title = goal.goal_identity?.title?.value ?? "Цель"; // Порядок разделов идёт по вопросам читателя: что это → действует ли на меня → // что делать → как считают → когда оценивают → кому писать → чем регламентировано → // что мешает → куда идти. Справочное и аудиторское уходит за черту в конец. const body = compact([ ...attentionBlocks(goal, options), ...meaningBlocks(goal, options), ...applicabilityBlocks(goal, options), ...executionBlocks(goal, options), ...metricsBlocks(goal, options), ...timelineBlocks(goal, options), ...completionBlocks(goal, options), ...responsibilityBlocks(goal, options), ...standardsBlocks(goal, options), ...implementationBlocks(goal, options), ...obstaclesBlocks(goal, options), ...outcomesBlocks(goal, options), ...relationsBlocks(goal, options), ...linksBlocks(goal, options) ]); // Оглавление нужно, когда по странице действительно есть куда прыгать. const sectionCount = body.filter((block) => block.type === "heading" && block.level === 2).length; const operational = [...headerCardBlocks(goal, options, { withToc: sectionCount >= 5 }), ...body]; const reference = auditTailBlocks(goal, options); const blocks = compact([ { type: "heading", level: 1, text: title, slice: true }, ...operational, ...(reference.length ? [{ type: "hr" }, ...compact(reference)] : []) ]); return { $schema: "wiki-layout-v1", blocks }; }