/** * FacilitatorViewAdapter — deterministic projection of the reasoning graph * into a concise, human-facing facilitator view (Version C). * * This adapter is pure and testable. It receives a prepared view model from * ReasoningWorkspace and returns a structured display model with up to four * primary sections: * * 1. What we know — supported observations, resolved state nodes * 2. Still investigating — unresolved unknowns, active unknown context * 3. Possible explanations — assumptions and tentative causal claims * 4. Quiet reasoning summary — secondary counts from the same graph * * Key design: this adapter classifies nodes by *semantic role* rather than * simply projecting graph kinds. Internal graph concepts (metrics, systems, * scaffolding, technical summaries) are suppressed from the user-facing view. * Translation quality matters more than layout completeness. * * All filtering, deduplication and ranking is deterministic and uses only * existing graph fields. No new backend data or API contracts are required. */ /* ── Normalisation helpers ─────────────────────────────────────── */ /** * Normalise a string for deduplication comparison. * Lowercase, trim, remove punctuation, collapse whitespace. */ function normaliseText(text) { if (!text || typeof text !== "string") return ""; return text .toLowerCase() .replace(/[^\w\s]/g, "") .replace(/\s+/g, " ") .trim(); } /** * Remove repeated boilerplate prefixes that add no meaning. */ function stripBoilerplate(text) { if (!text || typeof text !== "string") return text; const result = text.replace(/^need evidence about\s*/i, "").trim(); return result || null; } /** * Determine whether text is too long to scan usefully. */ function isTooLong(text, maxChars) { if (!text) return false; if (maxChars === undefined) maxChars = 280; return text.length > maxChars; } /* ── Filtering helpers ─────────────────────────────────────────── */ // Patterns that flag content as likely technical or boilerplate summary text. const TECHNICAL_SUMMARY_PATTERNS = [ /\bnodes?\s*[:\d]/i, /\bedges?\s*[:\d]/i, /\bsorted\s*/i, /by_kind/i, /\b(?:node|edge|unknown|state)\s+count/i, ]; // Patterns that flag content as scaffolding — structural glue the user does not need to see. const SCAFFOLDING_PATTERNS = [ // Scenario summaries and setup descriptions /\bsummary\s*of\s*(?:scenario|situation|problem|context|background)\b/i, /(?:^|\s)summary\s*[:\.]?\s*/i, // Process labels — the user cares about findings, not processes /\b(?:process|approach|workflow|methodology|procedure)\s+describes?\b/i, // System/tool references that are implementation details /\b(?:system|tool|platform|interface|framework|engine|library|component)\s+(?:for|that|which|used|providing|supporting)\b/i, /(?:logging|measurement|reporting|tracking|monitoring)\s+(?:system|tool|mechanism|framework|approach)\b/i, // Metric object descriptions (the metric itself is fine; describing the *object* is not) /\b(?:metric|measure|indicator|KPI)\s+describes?\b/i, /\b(?:metric|measure|indicator)\s+(?:captures?|tracks?|quantifies?|represents?)\b/i, // Graph artefacts — nodes describing themselves or other graph elements /(?:graph|diagram|visualization)\s+(?:showing|depicting|illustrating|displaying)\b/i, /\bnodes?\s*representing?\b/i, // "Current situation" type labels that are pure scaffolding /\b(?:current\s+)?(?:situation|state|scenario|context)\b.*\b(describes?|is|represents?|shows)\b/i, // Vague state-of-play descriptions /\b(?:is\s+(?:a\s+)?(?:situation|case|scenario|context|problem))\b/i, ]; // Patterns that flag content as implementation/technical vocabulary the user should not see. const INTERNAL_VOCAB_PATTERNS = [ // Technical summary language /\b(?:total|overall)\s+(?:count|number|figure)\s+of\b/i, /(?:complaint|incident|issue)\s+logging\s+(?:system|tool|mechanism|process)\b/i, /(?:performance|quality|production)\s+(?:measurement|monitoring)\s+(?:tools?|systems?)\b/i, // "Scaffolding" kind of description masquerading as content /\b(?:summary|overview|background\s+context)\s+of\b/i, ]; /** * Decide whether a raw graph text item should be included in the panel. * Returns { included, displayText, reason } where reason is null when accepted. */ function filterItem(raw) { const label = raw.label; const description = raw.description; const kind = raw.kind; // Extract display text — prefer whichever sounds most natural for human reading. let text; if (description && typeof description === "string" && description.trim()) { // Prefer the longer, more informative text. if (description !== label) { text = description; } else { text = label; } } else { text = label || ""; } if (!text || typeof text !== "string") return { included: false, reason: "empty" }; const trimmed = text.trim(); if (!trimmed) return { included: false, reason: "empty" }; // ── Scaffolding suppression (priority over technical summary) ── for (var i = 0; i < SCAFFOLDING_PATTERNS.length; i++) { if (SCAFFOLDING_PATTERNS[i].test(trimmed)) return { included: false, reason: "scaffolding" }; } // ── Internal vocabulary suppression ── for (var j = 0; j < INTERNAL_VOCAB_PATTERNS.length; j++) { if (INTERNAL_VOCAB_PATTERNS[j].test(trimmed)) return { included: false, reason: "internal-vocab" }; } // ── Technical summary suppression (existing) ── for (var k = 0; k < TECHNICAL_SUMMARY_PATTERNS.length; k++) { if (TECHNICAL_SUMMARY_PATTERNS[k].test(trimmed)) return { included: false, reason: "technical" }; } // Skip internal IDs — items whose text is just an ID or contains only one if (/^[a-z0-9-]{1,40}$/i.test(trimmed) && trimmed.length < 60) { return { included: false, reason: "internal-id" }; } // Depriorise items that are too long to scan usefully. // The adapter does not synthesise rewritten claims from verbose text. if (isTooLong(trimmed)) return { included: false, reason: "too-long" }; return { included: true, displayText: trimmed, sourceKind: kind }; } /* ── Node collection helpers ───────────────────────────────────── */ /** * Determine whether a node is resolved (explicitly closed). */ function isResolved(node, resolvedIds) { return resolvedIds.has(node.id) || node.status === "resolved"; } /** * Determine whether this node is the active unknown target. */ function isActiveUnknown(node, activeUnknownNodeId) { return node.id === activeUnknownNodeId; } /** * Determine whether a node's content represents established knowledge * (regardless of explicit resolution). Used for routing in Phase 1. * A kind=observation with status known is always an established observation. */ function isEstablished(node, resolvedIds) { if (node.kind === "observation" && node.status === "known") return true; // Already resolved nodes are also established (by ID or by status) if (resolvedIds.has(node.id)) return true; if (node.status === "resolved") return true; return false; } /* ── Semantic role classification ──────────────────────────────── */ /** * Classify a node by its semantic role in the investigation rather than its graph kind. * Returns one of: "observation", "question", "explanation", "scaffolding", "relationship". * * This allows the adapter to route content based on *meaning* rather than *type*. * A node that says "Complaints increased by 35%" is an observation regardless of kind. * A node whose only value is describing a process or summarising the scenario is scaffolding. */ function classifySemanticRole(node, resolvedIds) { var text = (node.description || node.label || "").trim().toLowerCase(); var kind = node.kind; // If filterItem rejected it as scaffolding/internal-vocab, treat it as scaffolding here too var filtered = filterItem(node); if (!filtered.included) { return "scaffolding"; } // Check resolved/established first for semantic routing. // A resolved unknown or assumption is still a question/explanation in origin, // but its content is now known. We return "observation" here so that Phase 1 // routing places it in the known section rather than investigating. if (resolvedIds && isEstablished(node, resolvedIds)) { return kind === "relationship" ? "relationship" : "observation"; } if (kind === "observation") return "observation"; if (kind === "unknown") return "question"; if (kind === "assumption") return "explanation"; if (kind === "relationship") return "relationship"; // kind === "state" or "metric" — need to look at content var isConcrete = !!( /\b\d+/.test(text) || // contains numbers /\b(?:increased|decreased|rose|fell|changed|improved|worsened)\b/i.test(text) || // contains change language /\b(?:per|from |to |across|during|over\s+\d)/i.test(text) || // temporal/quantitative /\b(?:about|approximately|around|roughly|exactly)\b/i.test(text) || /^\d/.test(text) // starts with a digit ); var isProcess = /describes?\s+(?:the\s+)?(?:current\s+)?(?:situation|state|scenario|problem|context)/i.test(text); var isSummary = /^summary/i.test(text) || /^(is\s+a\s+)?(situation|case|scenario|context)\b/i.test(text); if (isConcrete) return "observation"; if (isProcess || isSummary) return "scaffolding"; // Default: if it looks like a question or explanation from context, honour that. if (/\?$/.test(node.label || "")) return "question"; return "scaffolding"; } /* ── Core adapter function ─────────────────────────────────────── */ /** * Build a Version C facilitator view model from graph data. * * @param {Object} params * @param {Array} params.nodes — graph nodes * @param {Set} params.resolvedIds — resolved node IDs * @param {string|null} params.activeUnknownNodeId — ID of the active unknown * @param {Array} [params.edges=[]] — graph edges * @param {Object|null} [params.selectedQuestion=null] — current question object * @returns {Object} viewModel with sections: known, stillInvestigating, possibleExplanations, summaryCounts */ export function buildFacilitatorViewModel(_ref) { var nodes = _ref.nodes; var resolvedIds = _ref.resolvedIds; var activeUnknownNodeId = _ref.activeUnknownNodeId; var edges = _ref.edges; var selectedQuestion = _ref.selectedQuestion; if (!nodes) nodes = []; if (!resolvedIds) resolvedIds = new Set(); if (activeUnknownNodeId === undefined || activeUnknownNodeId === null) activeUnknownNodeId = null; if (!edges) edges = []; if (!selectedQuestion) selectedQuestion = null; // ── Phase 1: Categorise all nodes by semantic role ────────── var known = []; var stillInvestigating = []; var possibleExplanations = []; for (var _i = 0; _i < nodes.length; _i++) { var node = nodes[_i]; var resolved = isResolved(node, resolvedIds); var established = isEstablished(node, resolvedIds); var semanticRole = classifySemanticRole(node, resolvedIds); var displayResult = filterItem(node); if (!displayResult.included) continue; // Scaffold items are entirely suppressed from user-facing sections. if (semanticRole === "scaffolding") continue; var entry = { text: displayResult.displayText, normalised: normaliseText(displayResult.displayText), kind: node.kind, semanticRole: semanticRole, confidence: node.confidence || null, isResolved: resolved, isActiveUnknown: isActiveUnknown(node, activeUnknownNodeId), evidenceIds: node.evidenceIds || [], relevance: node.relevance != null ? node.relevance : null, priority: node.priority != null ? node.priority : null, }; // ── Established content goes to "known" ── if (established) { known.push(entry); continue; } // ── Unresolved content routing by semantic role ── switch (semanticRole) { case "question": stillInvestigating.push(entry); break; case "explanation": possibleExplanations.push(entry); break; case "observation": // Unresolved observations are uncertain — put in investigating. stillInvestigating.push(entry); break; case "relationship": known.push(entry); break; default: stillInvestigating.push(entry); } } // ── Phase 2: Deduplicate by normalised text ───────────────── /** * Deduplicate entries within a single list. * First occurrence wins; if a later entry has a higher-priority kind, replace it. */ function deduplicate(entries) { var seen = {}; // normalised → first entry return entries.filter(function (entry) { var key = entry.normalised; if (!key || !seen.hasOwnProperty(key)) { seen[key] = entry; return true; } // If already seen, prefer the one with a more specific kind order: // observation > unknown > assumption > state > metric var priorityOrder = ["observation", "unknown", "assumption", "state", "metric"]; var existingKindIdx = priorityOrder.indexOf(seen[key].kind); var newKindIdx = priorityOrder.indexOf(entry.kind); if (newKindIdx < existingKindIdx) { seen[key] = entry; return true; // replace with this one } return false; // skip — earlier winner stays }); } // Apply deduplication within each section independently var knownDedup = deduplicate(known); var unknownDedup = deduplicate(stillInvestigating); var assumptionDedup = deduplicate(possibleExplanations); // Cross-deduplicate: if "known" and "stillInvestigating" share normalised text, // move the item to stillInvestigating (uncertainty wins). var knownFinal = knownDedup; var stillInvestigatingFinal = unknownDedup; if (knownDedup.length > 0 && unknownDedup.length > 0) { var knownTexts = {}; for (var _j = 0; _j < unknownDedup.length; _j++) { knownTexts[unknownDedup[_j].normalised] = true; } knownFinal = knownDedup.filter(function (e) { return !knownTexts[e.normalised]; }); } // ── Phase 3: Rank items within each section ───────────────── /** * Rank known items. * Order: observations > questions/resolved unknowns > explanations/resolved assumptions > relationships > other. * Within each group: high-confidence > medium > low > concise. */ function rankKnown(items) { var confidenceRank = {}; confidenceRank["high"] = 0; confidenceRank["medium"] = 1; confidenceRank["low"] = 2; confidenceRank["null"] = 3; var rolePriority = {}; rolePriority["observation"] = 0; rolePriority["question"] = 1; rolePriority["explanation"] = 2; rolePriority["relationship"] = 3; rolePriority["scaffolding"] = 4; return items.slice().sort(function (a, b) { // Prefer semantic observations first var ra = rolePriority[a.semanticRole] != null ? rolePriority[a.semanticRole] : 4; var rb = rolePriority[b.semanticRole] != null ? rolePriority[b.semanticRole] : 4; if (ra !== rb) return ra - rb; // Then by confidence var ca = confidenceRank[a.confidence] != null ? confidenceRank[a.confidence] : 3; var cb = confidenceRank[b.confidence] != null ? confidenceRank[b.confidence] : 3; if (ca !== cb) return ca - cb; // Prefer concise items if (a.text.length !== b.text.length) return a.text.length - b.text.length; return 0; }); } /** * Rank still-investigating items. * Order: active unknown > selected-question target > explicit priority > evidence-linked > concise > remaining. */ function rankUnknowns(items) { var sqText = selectedQuestion && selectedQuestion.question ? normaliseText(selectedQuestion.question) : null; return items.slice().sort(function (a, b) { // Active unknown always first if (a.isActiveUnknown && !b.isActiveUnknown) return -1; if (!a.isActiveUnknown && b.isActiveUnknown) return 1; // Selected-question target: match by normalised text if (sqText && a.normalised === sqText && b.normalised !== sqText) return -1; if (sqText && a.normalised !== sqText && b.normalised === sqText) return 1; // Explicit priority fields where available in the graph var pa = a.priority != null ? a.priority : null; var pb = b.priority != null ? b.priority : null; if (pa != null && pb != null && pa !== pb) return pa - pb; // Prefer items linked to supported observations (has evidenceIds) var ae = a.evidenceIds ? a.evidenceIds.length : 0; var be = b.evidenceIds ? b.evidenceIds.length : 0; if (ae > be) return -1; if (be > ae) return 1; // Prefer concise items (shorter labels are more scannable) if (a.text.length !== b.text.length) return a.text.length - b.text.length; return 0; }); } /** * Rank possible explanation items. * Order: assumptions linked to supported observations > related to active unknown > concise > remaining. * Fallback ordering is by length (concise first), then kind preference. */ function rankAssumptions(items) { return items.slice().sort(function (a, b) { // Prefer assumptions with evidence linkage var ae = a.evidenceIds ? a.evidenceIds.length : 0; var be = b.evidenceIds ? b.evidenceIds.length : 0; if (ae > be) return -1; if (be > ae) return 1; // Then by length (concise first) if (a.text.length !== b.text.length) return a.text.length - b.text.length; return 0; }); } var rankedKnown = rankKnown(knownFinal); var rankedUnknowns = rankUnknowns(stillInvestigatingFinal); var rankedAssumptions = rankAssumptions(assumptionDedup); // ── Phase 4: Apply display limits ──────────────────────────── var MAX_KNOWN = 4; var MAX_INVESTIGATING = 4; var MAX_EXPLANATIONS = 3; var knownDisplay = rankedKnown.slice(0, MAX_KNOWN); var investigatingDisplay = rankedUnknowns.slice(0, MAX_INVESTIGATING); var explanationsDisplay = rankedAssumptions.slice(0, MAX_EXPLANATIONS); // ── Phase 5: Build display model ───────────────────────────── function toItemDisplay(entry) { return entry.text; } // Determine section titles based on investigation state var hasUnresolvedUnknowns = investigatingDisplay.some(function (e) { return e.kind === "unknown"; }); var knownSectionTitle = "What we know"; var isTerminal = !selectedQuestion && nodes.length > 0; if (isTerminal) { knownSectionTitle = "What the evidence supports"; } var investigatingSectionTitle = "Still investigating"; if (isTerminal && hasUnresolvedUnknowns) { investigatingSectionTitle = "Remaining cautions"; } // ── Phase 6: Compute quiet summary counts ──────────────────── // Count all items from the graph (including resolved), displayed as plain-language labels. var totalObservations = 0; for (var _k = 0; _k < nodes.length; _k++) { if (nodes[_k].kind === "observation" && filterItem(nodes[_k]).included) { totalObservations++; } } var unresolvedUnknownsCount = 0; for (var _l = 0; _l < nodes.length; _l++) { if (nodes[_l].kind === "unknown" && !isResolved(nodes[_l], resolvedIds)) { unresolvedUnknownsCount++; } } var unresolvedAssumptionsCount = 0; for (var _m = 0; _m < nodes.length; _m++) { if (nodes[_m].kind === "assumption" && !isResolved(nodes[_m], resolvedIds)) { unresolvedAssumptionsCount++; } } var relationshipsCount = edges ? edges.length : 0; // Build plain-language label string — only include non-zero counts. var summaryParts = []; if (totalObservations > 0) { summaryParts.push(totalObservations + " observation" + (totalObservations !== 1 ? "s" : "")); } if (unresolvedUnknownsCount > 0) { summaryParts.push(unresolvedUnknownsCount + " open question" + (unresolvedUnknownsCount !== 1 ? "s" : "")); } if (unresolvedAssumptionsCount > 0) { summaryParts.push(unresolvedAssumptionsCount + " assumption" + (unresolvedAssumptionsCount !== 1 ? "s" : "")); } // ── Phase 7: Determine terminal framing for investigating section ── var investigatingSectionHasItems = false; if (!isTerminal) { investigatingSectionHasItems = investigatingDisplay.length > 0; } else { investigatingSectionHasItems = hasUnresolvedUnknowns || explanationsDisplay.length > 0; } return { known: { title: knownSectionTitle, items: knownDisplay.map(toItemDisplay), hasItems: knownDisplay.length > 0, }, investigating: { title: investigatingSectionTitle, items: investigatingDisplay.map(toItemDisplay), hasItems: investigatingSectionHasItems, // Flag for the component to know whether to omit this section entirely. shouldOmit: isTerminal && !hasUnresolvedUnknowns && explanationsDisplay.length === 0, }, explanations: { title: "Possible explanations", items: explanationsDisplay.map(function (entry) { return { text: entry.text, // Structural uncertainty label — never depends on colour. label: entry.evidenceIds && entry.evidenceIds.length > 0 ? "To be tested" : "Not yet established", }; }), hasItems: explanationsDisplay.length > 0, }, summary: { text: summaryParts.length > 0 ? summaryParts.join(" · ") : null, }, _meta: { isTerminal: isTerminal, hasUnresolvedUnknowns: hasUnresolvedUnknowns, totalObservations: totalObservations, unresolvedUnknownsCount: unresolvedUnknownsCount, unresolvedAssumptionsCount: unresolvedAssumptionsCount, }, }; } export default buildFacilitatorViewModel;