Files
ThothII/harness/.pi/extensions/gate/enrich.js
T
marcopanandClaude Fable 5 e24b41b156 feat(opt): three efficiency levers for NL→SQL workflow
Lever 1: Join-graph via FK logics in annotations + suggest-fks command
  - TableAnnotation.foreign_keys field stores curated logical FKs (DWH has no FK constraints)
  - tht schema suggest-fks: mine from approved SQL, heuristics (time_key → dim_time),
    same-name discovery + explicit --assume flag for multi-owner PKs
  - mschema renders 【Foreign keys】 section populated; validation in merge.py
  - SKILL.md F4 now reads FKs from mschema-text, no custom data_time_key logic

Lever 2: Context-pack consolidation at kickoff (tht search pack)
  - Single embedding of question, reused for schema + evidence + solved searches
  - One command: tht search pack <question> --session <id> → retrieval_pack.md
  - Graceful degradation when Ollama/vector store unreachable (exit 0, empty sections)
  - SKILL.md F1 prescribes as first call; reduces model thinking turns via pre-retrieval

Lever 3: Phase-summary recap v2 auto-construction from session ledger
  - tht session show --json includes full decisions ledger
  - tht phase meta --json exports 'emits' (substantive decision types per phase)
  - Gate appends deterministic 【Decisioni registrate in questa fase】 section (appendLedgerSection)
  - Model authors only summary + checks; recap table comes from persisted state (exact by construction)
  - SKILL.md Disciplina 6: brief model output, gate fills the rest

Tests: 358 Python (including 10 FK + 3 pack + 1 session-ledger tests) + 111 JS gate tests, all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:43:08 +02:00

183 lines
6.4 KiB
JavaScript

// enrich.js -- PURE merge/enrichment for the v2 gate payloads (WS2).
//
// Catalog lookups are INJECTED as `getColumns` params (for testability): a function
// (tableName) => { description, columns: [{ name, description, ... }] } | null.
// A catalog miss (null) yields empty descriptions, never an error (the hard-fail
// stays only in reviewer_schema_linking).
//
// No dependency on tht-gate.js: importable and testable in isolation.
"use strict";
// Memoize a raw catalog lookup so repeated table hits do a single I/O call. The
// gate wraps its `tht schema columns <t> --json` (try/catch -> null on miss) and
// passes it here; enrich itself stays pure.
function memoizeGetColumns(rawGetColumns) {
const cache = new Map();
return function getColumns(tableName) {
if (cache.has(tableName)) return cache.get(tableName);
const v = rawGetColumns(tableName);
cache.set(tableName, v);
return v;
};
}
function columnDescription(cat, colName) {
if (!cat || !Array.isArray(cat.columns)) return "";
const col = cat.columns.find((c) => c.name === colName);
return (col && col.description) || "";
}
// Contract A. Fills index (1-based), tables[].description and filters[].description
// via getColumns. For filters, `column: "tab.col"` resolves to the column's catalog
// description. Returns a NEW object; model-owned fields pass through unchanged.
function enrichCtePlanV2(data, getColumns) {
const ctes = (data.ctes || []).map((cte, i) => {
const out = { ...cte, index: i + 1 };
if (Array.isArray(cte.tables)) {
out.tables = cte.tables.map((t) => {
const cat = getColumns(t.name);
return { ...t, description: (cat && cat.description) || "" };
});
}
if (Array.isArray(cte.filters)) {
out.filters = cte.filters.map((f) => {
const out2 = { ...f };
if (typeof f.column === "string" && f.column.includes(".")) {
const idx = f.column.indexOf(".");
const tableName = f.column.slice(0, idx);
const colName = f.column.slice(idx + 1);
out2.description = columnDescription(getColumns(tableName), colName);
} else {
out2.description = "";
}
return out2;
});
}
return out;
});
return { ...data, ctes };
}
// Contract B. Fuses the model's thin data with `tht cte info` output.
// name/index/total/sql <- cteInfo
// purpose/rationale/depends_on <- cteInfo.doc (fallback: thinData)
// status/execution_ms/row_sample/warnings/sql_hash/preview <- cteInfo.last_test
// note <- thinData
// preview = { columns: last_test.columns, rows: last_test.preview_rows }.
function buildCteResultV2(thinData, cteInfo) {
const thin = thinData || {};
const doc = cteInfo.doc || {};
const lt = cteInfo.last_test || {};
const pick = (docVal, thinVal) => (docVal !== undefined ? docVal : thinVal);
return {
schema_version: 2,
name: cteInfo.name,
index: cteInfo.index,
total: cteInfo.total,
purpose: pick(doc.purpose, thin.purpose),
rationale: pick(doc.rationale, thin.rationale),
depends_on: pick(doc.depends_on, thin.depends_on),
sql: cteInfo.sql,
status: lt.status,
execution_ms: lt.execution_ms,
row_sample: lt.row_sample,
warnings: lt.warnings || [],
sql_hash: lt.sql_hash,
columns: (lt.columns || []).map((name) => ({ name, description: "" })),
preview: { columns: lt.columns || [], rows: lt.preview_rows || [] },
note: thin.note,
};
}
// Contract C. Fills `phase` from phaseMeta and the `description` fields in
// tables[]/tables[].columns[]/sections[].items[] via getColumns. phaseMeta is
// { id, num, name }. Returns a NEW object.
function enrichPhaseSummaryV2(data, phaseMeta, getColumns) {
const out = { ...data, phase: phaseMeta };
if (Array.isArray(data.tables)) {
out.tables = data.tables.map((t) => {
const cat = getColumns(t.name);
const enriched = { ...t, description: (cat && cat.description) || "" };
if (Array.isArray(t.columns)) {
enriched.columns = t.columns.map((c) => ({
...c,
description: columnDescription(cat, c.name),
}));
}
return enriched;
});
}
if (Array.isArray(data.sections)) {
out.sections = data.sections.map((s) => {
if (!Array.isArray(s.items)) return { ...s };
return {
...s,
items: s.items.map((it) => {
// An item's description resolves against its declared table+column.
let desc = "";
if (typeof it.table === "string" && typeof it.column === "string") {
desc = columnDescription(getColumns(it.table), it.column);
} else if (typeof it.table === "string") {
const cat = getColumns(it.table);
desc = (cat && cat.description) || "";
}
return { ...it, description: desc };
}),
};
});
}
return out;
}
// Fills columns[].description of a built cte_result payload by matching each column
// name against the catalog of the plan's tables (`planTables`: string[]). First
// table whose catalog carries the column wins; unresolved -> empty description.
// Mutates a copy of payload.columns; returns the payload.
function enrichCteResultColumns(payload, planTables, getColumns) {
if (!Array.isArray(payload.columns)) return payload;
const tables = Array.isArray(planTables) ? planTables : [];
const columns = payload.columns.map((col) => {
let desc = "";
for (const t of tables) {
const d = columnDescription(getColumns(t), col.name);
if (d) {
desc = d;
break;
}
}
return { ...col, description: desc };
});
return { ...payload, columns };
}
// Contract C bis. Appends a deterministic "decisions of this phase" section built
// from the session ledger, so the model's recap can stay thin (Discipline 6 exactness
// comes from persisted state, not model prose). `decisions` is session-show's ledger
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
function appendLedgerSection(data, decisions, emits) {
const emitSet = new Set(emits || []);
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
if (rows.length === 0) return data;
const section = {
title: "Decisioni registrate in questa fase (dal ledger)",
items: rows.map((d) => ({
label: `${d.type}: ${d.subject}`,
value: d.detail || "",
kind: "decision",
rationale: d.rationale || "",
})),
};
return { ...data, sections: [...(data.sections || []), section] };
}
module.exports = {
memoizeGetColumns,
enrichCtePlanV2,
buildCteResultV2,
enrichCteResultColumns,
enrichPhaseSummaryV2,
appendLedgerSection,
};