1189 lines
50 KiB
Markdown
1189 lines
50 KiB
Markdown
# Active Memory (Promotion Gate + Solved Questions) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Make the tht memory system *active*: (A) a reviewer gate at end-of-workflow (F8) that proposes the session's reusable decisions for promotion into the global memory, and (B) automatic indexing of the question→SQL pair at `tht session finalize`, with recall commands consumable in F4/F6/F7.
|
||
|
||
**Architecture:** Part A adds two decision types (`memory_promoted`/`memory_promotion_declined`), a declined-candidates filter in `tht/memory.py`, and a new deterministic gate tool `reviewer_memory_promote` in `tht-gate.js` that fetches candidates via `tht memory promote --preview --json`, shows a pre-selected multiselect, then persists via `tht memory save-one` + ledger decisions. Part B adds a `solved_question` vector kind (stored in the existing `memory` pgvector table — **no server DDL**), a `tht/solved.py` module (record build + D11-style one-row upsert), two CLI commands (`tht memory solved-index`, `tht memory solved-search`), and a best-effort hook in `finalize_cmd`. SKILL.md is updated to prescribe both flows.
|
||
|
||
**Tech Stack:** Python (typer CLI, pydantic, pytest in `harness/.venv`), JavaScript (Pi extension, `node:test` via `npm test` in `harness/`). No frontend changes: the promotion gate reuses the existing `multiselect` widget descriptor already rendered by the browser.
|
||
|
||
## Global Constraints
|
||
|
||
- `tht`'s `-c`/`--config` is a PER-COMMAND option: always AFTER the subcommand (project gotcha; the gate never passes it — config resolves from env/default).
|
||
- `--json` output must be pristine: only valid JSON on stdout (warnings go to stderr with `err=True`).
|
||
- User-facing CLI/gate copy is Italian (match existing messages); SKILL.md prose is English with Italian example content (match existing file).
|
||
- Python: ruff line-length 100 (`.venv/bin/ruff check .` from `harness/`). No ESLint on JS; match existing tab-indented style in `tht-gate.js`.
|
||
- Run Python tests with `harness/.venv/bin/pytest -q` (l2 excluded by default); JS gate tests with `npm test` from `harness/`.
|
||
- Do NOT touch `frontend/` or `backend/`.
|
||
- The pgvector is remote: workstation writes go ONLY through `vector_write_rest` (writer key, upsert-only, no deletes). Never introduce a `VectorStore.sync` call for `solved_question` records (its delete-stale step would wipe other sessions' records).
|
||
- The model must never bypass the gate: promotion CLI commands get added to the bash `FORBIDDEN` list (the gate itself calls the CLI via `execFileSync`, which the hook does not intercept).
|
||
|
||
## File Structure
|
||
|
||
| File | Change | Responsibility |
|
||
|---|---|---|
|
||
| `harness/tht/decisions.py` | modify | +2 decision types |
|
||
| `harness/workflow.yaml` | modify | F8 `emits` the new types |
|
||
| `harness/tht/memory.py` | modify | declined-seq filter; `_question_context` → public `question_context` |
|
||
| `harness/.pi/extensions/tht-gate.js` | modify | `reviewer_memory_promote` tool + pure helpers + FORBIDDEN |
|
||
| `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js` | create | L1 tests for the pure helpers |
|
||
| `harness/tht/solved.py` | create | solved-question record build + one-row upsert |
|
||
| `harness/tht/vectorstore/reader.py` | modify | `solved_question` → `memory` table mapping |
|
||
| `harness/tht/vectorstore/rest_writer.py` | modify | allow kind in `memory` table |
|
||
| `harness/tht/cli/memory_cmd.py` | modify | `solved-index`, `solved-search`, `index_solved_session` |
|
||
| `harness/tht/cli/session_cmd.py` | modify | best-effort indexing hook in `finalize_cmd` |
|
||
| `harness/.pi/skills/tht-sessione/SKILL.md` | modify | prescribe promotion (F2/F5/F8) + recall (F4/F6/F7, Session end) |
|
||
| `harness/tests/test_memory_promotion.py` | create | Tasks 1–2 tests |
|
||
| `harness/tests/test_solved_question.py` | create | Task 5 tests |
|
||
| `harness/tests/test_solved_build.py` | create | Task 6 tests |
|
||
| `PROJECT_STATE.md` | modify | snapshot entry |
|
||
|
||
Design notes locked in:
|
||
|
||
- **Promotion runs INSIDE F8** (after the datamart decision, before `reviewer_confirm kind:"phase"`), so the ledger decisions are recorded at phase 8 and the workflow closes with a complete audit trail before finalize. The reusable types were each individually reviewer-approved earlier, and `sql_approved` already exists by end of F7 — no need to wait for the finalize battery.
|
||
- **Declined candidates carry `detail: "seq:<decision_seq>"`** on the `memory_promotion_declined` decision; `declined_promotion_seqs()` parses it so a re-opened promotion gate never re-proposes them. Promoted ones are already deduped by the registry key `(session_id, decision_seq)`.
|
||
- **`solved_question` reuses the `memory` pgvector table** (kinds already share tables: `schema_table`/`schema_column` share `schema_records`). `KIND_TO_TABLE`/`TABLE_TO_KINDS` get the mapping; the REST reader filters by kind client-side. No migration.
|
||
- **Embedding = the rewritten question only** (`record.content`); the SQL lives in `metadata`. The dedup hash therefore covers question+SQL (`_solved_hash`), so a re-finalize that changes only the SQL still updates the row. `solved_question` records never flow through `sync()` (documented in the module).
|
||
- **Finalize never fails on indexing**: the hook wraps `index_solved_session` in try/except and degrades to a yellow warning (missing writer key, VPN down, etc.).
|
||
- The integration test `tests/integration/test_gate_cli_signatures.py` extracts gate CLI call sites automatically (`memory` is already in `_GROUPS`), so the new gate calls are covered without editing it.
|
||
|
||
---
|
||
|
||
### Task 1: Decision types + workflow emits
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/decisions.py` (the `DecisionType` Literal, after `"datamart_declined",`)
|
||
- Modify: `harness/workflow.yaml` (F8 `emits`)
|
||
- Test: `harness/tests/test_memory_promotion.py` (create)
|
||
|
||
**Interfaces:**
|
||
- Produces: decision types `"memory_promoted"` and `"memory_promotion_declined"` valid in `DecisionRecord` and accepted by `tht decision add` from phase 8 (`Workflow.decision_min_phase(...) == 8`). Convention consumed by Tasks 2–3: `subject` = the original decision's subject, `detail` = `"seq:<decision_seq>"`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```python
|
||
"""L1: gate di promozione memorie (F8) — tipi di decisione + filtro candidati.
|
||
|
||
La promozione era solo CLI facoltativa (mai innescata): il gate reviewer_memory_promote
|
||
la rende un passo del workflow. Questi test fissano il contratto harness-side:
|
||
- i due nuovi decision type esistono e sono ammessi dalla Fase 8 (workflow.yaml emits)
|
||
- i candidati rifiutati al gate (memory_promotion_declined, detail "seq:<n>") non
|
||
vengono riproposti da reusable_promotions/preview_promotions.
|
||
"""
|
||
from datetime import datetime
|
||
|
||
from tht.decisions import DecisionRecord
|
||
from tht.workflow import load_workflow
|
||
|
||
|
||
def test_promotion_decision_types_are_valid():
|
||
for t in ("memory_promoted", "memory_promotion_declined"):
|
||
d = DecisionRecord(
|
||
seq=1, ts=datetime(2026, 1, 1), type=t, subject="fact_x", detail="seq:5"
|
||
)
|
||
assert d.type == t
|
||
|
||
|
||
def test_promotion_decision_types_min_phase_is_f8():
|
||
wf = load_workflow()
|
||
assert wf.decision_min_phase("memory_promoted") == 8
|
||
assert wf.decision_min_phase("memory_promotion_declined") == 8
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v`
|
||
Expected: FAIL — pydantic `ValidationError` (type not in Literal) on the first test; `decision_min_phase` returning 1 on the second.
|
||
|
||
- [ ] **Step 3: Add the two types to `DecisionType`**
|
||
|
||
In `harness/tht/decisions.py`, after the lines
|
||
```python
|
||
"datamart_requested",
|
||
"datamart_declined",
|
||
```
|
||
insert:
|
||
```python
|
||
# F8: promozione memorie riusabili al gate reviewer_memory_promote. subject =
|
||
# subject della decisione originale, detail = "seq:<decision_seq>" (usato da
|
||
# declined_promotion_seqs per non riproporre i candidati rifiutati).
|
||
"memory_promoted",
|
||
"memory_promotion_declined",
|
||
```
|
||
|
||
- [ ] **Step 4: Add the types to F8's `emits` in `harness/workflow.yaml`**
|
||
|
||
Replace:
|
||
```yaml
|
||
emits: [datamart_requested, datamart_declined]
|
||
```
|
||
with:
|
||
```yaml
|
||
emits: [datamart_requested, datamart_declined, memory_promoted, memory_promotion_declined]
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v`
|
||
Expected: 2 PASS
|
||
|
||
- [ ] **Step 6: Run the full L1 suite + lint (no regressions)**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .`
|
||
Expected: all pass, no lint errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/decisions.py harness/workflow.yaml harness/tests/test_memory_promotion.py
|
||
git commit -m "feat(memory): memory_promoted/memory_promotion_declined decision types (F8 emits)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Declined-candidates filter in memory.py
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/memory.py`
|
||
- Test: `harness/tests/test_memory_promotion.py` (extend)
|
||
|
||
**Interfaces:**
|
||
- Consumes: the `detail: "seq:<n>"` convention from Task 1.
|
||
- Produces: `declined_promotion_seqs(decisions: list[DecisionRecord]) -> set[int]`; `reusable_promotions(...)` (same signature) now excludes declined seqs; `question_context(decisions, manifest) -> str` (public rename of `_question_context`, consumed by Task 6).
|
||
|
||
- [ ] **Step 1: Write the failing tests (append to `tests/test_memory_promotion.py`)**
|
||
|
||
```python
|
||
from tht.decisions import append_decision
|
||
from tht.memory import declined_promotion_seqs, reusable_promotions
|
||
from tht.session.models import SessionManifest
|
||
|
||
|
||
def _manifest() -> SessionManifest:
|
||
return SessionManifest(
|
||
id="s1", created_at=datetime(2026, 1, 1), question="domanda originale",
|
||
database="db", schema="public",
|
||
)
|
||
|
||
|
||
def test_declined_promotion_seqs_parses_seq_detail():
|
||
d = DecisionRecord(
|
||
seq=9, ts=datetime(2026, 1, 1), type="memory_promotion_declined",
|
||
subject="fact_x", detail="seq:5",
|
||
)
|
||
assert declined_promotion_seqs([d]) == {5}
|
||
|
||
|
||
def test_declined_promotion_seqs_ignores_malformed_and_other_types():
|
||
ds = [
|
||
DecisionRecord(seq=1, ts=datetime(2026, 1, 1),
|
||
type="memory_promotion_declined", subject="x", detail=""),
|
||
DecisionRecord(seq=2, ts=datetime(2026, 1, 1),
|
||
type="table_promoted", subject="x", detail="seq:3"),
|
||
]
|
||
assert declined_promotion_seqs(ds) == set()
|
||
|
||
|
||
def test_reusable_promotions_exclude_declined(tmp_path):
|
||
append_decision(tmp_path, type="table_promoted", subject="fact_a",
|
||
detail="tab principale", rationale="scelta reviewer") # seq 1
|
||
append_decision(tmp_path, type="concept_clarified", subject="attivo",
|
||
detail="flag_attivo = TRUE") # seq 2
|
||
append_decision(tmp_path, type="memory_promotion_declined",
|
||
subject="fact_a", detail="seq:1") # seq 3
|
||
cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl")
|
||
assert [c.decision_seq for c in cand] == [2]
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py -v`
|
||
Expected: FAIL with `ImportError: cannot import name 'declined_promotion_seqs'`.
|
||
|
||
- [ ] **Step 3: Implement in `harness/tht/memory.py`**
|
||
|
||
(a) After the `decided_memory_ids` function, add:
|
||
|
||
```python
|
||
_DECLINED_SEQ_RE = re.compile(r"\bseq:(\d+)\b")
|
||
|
||
|
||
def declined_promotion_seqs(decisions: list[DecisionRecord]) -> set[int]:
|
||
"""decision_seq dei candidati che il reviewer ha rifiutato al gate di promozione
|
||
(F8, `memory_promotion_declined` con detail "seq:<n>"): una riapertura del gate
|
||
non deve riproporli. I promossi sono gia' dedupati dal registro."""
|
||
out: set[int] = set()
|
||
for d in decisions:
|
||
if d.type != "memory_promotion_declined":
|
||
continue
|
||
m = _DECLINED_SEQ_RE.search(d.detail or "")
|
||
if m:
|
||
out.add(int(m.group(1)))
|
||
return out
|
||
```
|
||
|
||
(b) Rename `_question_context` → `question_context` (public: Task 6's solved-record build reuses it) and update its one internal call site in `_compute_promotions`:
|
||
|
||
```python
|
||
def question_context(decisions: list[DecisionRecord], manifest: SessionManifest) -> str:
|
||
rewritten = [d for d in decisions if d.type == "question_rewritten"]
|
||
return rewritten[-1].detail if rewritten else manifest.question
|
||
```
|
||
(in `_compute_promotions`: `context = question_context(decisions, manifest)`)
|
||
|
||
(c) Replace the body of `reusable_promotions` with:
|
||
|
||
```python
|
||
def reusable_promotions(
|
||
session_dir: Path, manifest: SessionManifest, registry_path: Path
|
||
) -> list[MemoryRecord]:
|
||
"""Candidati riusabili (tipi in REUSABLE_TYPES) non ancora promossi ne' rifiutati
|
||
al gate, SENZA cap: il chiamante applica MAX_PROMOTION_CANDIDATES e segnala il
|
||
troncamento."""
|
||
from tht.phase import effective_decisions
|
||
|
||
cand = _compute_promotions(
|
||
session_dir, manifest, seqs=None, existing=load_registry(registry_path)
|
||
)
|
||
declined = declined_promotion_seqs(effective_decisions(session_dir))
|
||
return [
|
||
c for c in cand if c.type in REUSABLE_TYPES and c.decision_seq not in declined
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_memory_promotion.py tests/test_memory_metadata.py tests/test_memory_save_one.py -v`
|
||
Expected: all PASS (the rename must not break existing memory tests).
|
||
|
||
- [ ] **Step 5: Full suite + lint**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/memory.py harness/tests/test_memory_promotion.py
|
||
git commit -m "feat(memory): filter gate-declined candidates from promotion preview"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Gate tool `reviewer_memory_promote`
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/extensions/tht-gate.js`
|
||
- Test: `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `tht memory promote --session <id> --preview --json` (existing; emits `[{decision_seq, type, subject, detail, rationale, question_context, tables, concepts}]`), `tht memory save-one --session <id> --decision <seq> --json` (existing), `decisionAddArgs` / `buildMultiselectRequest` / `emitAndWait` / `relayIfThtFails` (existing in-file), decision types from Task 1.
|
||
- Produces: registered Pi tool `reviewer_memory_promote {session: string}`; exported pure helpers `promotionOptions(candidates)`, `promotionContent(candidates)`, `splitPromotionChoices(candidates, choices)`.
|
||
|
||
- [ ] **Step 1: Write the failing JS tests**
|
||
|
||
Create `harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js`:
|
||
|
||
```js
|
||
const test = require("node:test");
|
||
const assert = require("node:assert");
|
||
const {
|
||
promotionOptions,
|
||
promotionContent,
|
||
splitPromotionChoices,
|
||
} = require("../../tht-gate.js");
|
||
|
||
// F8 memory-promotion gate: the candidates are DETERMINISTIC (computed by
|
||
// `tht memory promote --preview --json`), the model only names the session.
|
||
// These tests pin the pure candidate->widget mapping and the choice partition.
|
||
|
||
const CANDIDATES = [
|
||
{ decision_seq: 3, type: "table_promoted", subject: "fact_seeablazione",
|
||
detail: "tabella principale ablazioni", rationale: "scelta dal reviewer",
|
||
question_context: "quante ablazioni nel 2023", tables: ["fact_seeablazione"], concepts: [] },
|
||
{ decision_seq: 5, type: "concept_clarified", subject: "paziente attivo",
|
||
detail: "flag_attivo = TRUE", rationale: "",
|
||
question_context: "quante ablazioni nel 2023", tables: [], concepts: ["paziente attivo"] },
|
||
];
|
||
|
||
test("promotionOptions maps candidates to seq-keyed options", () => {
|
||
assert.deepEqual(promotionOptions(CANDIDATES), [
|
||
{ id: "seq-3", label: "table_promoted: fact_seeablazione" },
|
||
{ id: "seq-5", label: "concept_clarified: paziente attivo" },
|
||
]);
|
||
});
|
||
|
||
test("promotionContent lists every candidate with detail and context", () => {
|
||
const c = promotionContent(CANDIDATES);
|
||
assert.ok(c.includes("fact_seeablazione"));
|
||
assert.ok(c.includes("flag_attivo = TRUE"));
|
||
assert.ok(c.includes("quante ablazioni nel 2023"));
|
||
});
|
||
|
||
test("splitPromotionChoices partitions by selection", () => {
|
||
const { promote, decline } = splitPromotionChoices(CANDIDATES, ["seq-5"]);
|
||
assert.deepEqual(promote.map((c) => c.decision_seq), [5]);
|
||
assert.deepEqual(decline.map((c) => c.decision_seq), [3]);
|
||
});
|
||
|
||
test("empty or missing choices declines everything", () => {
|
||
assert.equal(splitPromotionChoices(CANDIDATES, []).decline.length, 2);
|
||
assert.equal(splitPromotionChoices(CANDIDATES, undefined).decline.length, 2);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && npm test`
|
||
Expected: the new file FAILS (`promotionOptions is not a function`); the rest of the suite passes.
|
||
|
||
- [ ] **Step 3: Add the pure helpers to `tht-gate.js`**
|
||
|
||
Immediately after the `shouldSkipEmptyDecide` export, add:
|
||
|
||
```js
|
||
// --- F8 memory-promotion gate: pure candidate->widget mapping (L1-tested) ------
|
||
// The candidates come from `tht memory promote --preview --json` (deterministic,
|
||
// reviewer-approved decisions only); the model never authors them.
|
||
export function promotionOptions(candidates) {
|
||
return candidates.map((c) => ({
|
||
id: `seq-${c.decision_seq}`,
|
||
label: `${c.type}: ${c.subject}`,
|
||
}));
|
||
}
|
||
|
||
export function promotionContent(candidates) {
|
||
return candidates
|
||
.map(
|
||
(c) =>
|
||
`- **${c.type}: ${c.subject}** (decisione #${c.decision_seq})\n` +
|
||
` ${c.detail || ""}\n` +
|
||
` Motivo: ${c.rationale || "—"}\n` +
|
||
` Domanda di contesto: ${c.question_context || "—"}`,
|
||
)
|
||
.join("\n");
|
||
}
|
||
|
||
export function splitPromotionChoices(candidates, choices) {
|
||
const chosen = new Set(choices ?? []);
|
||
const promote = [];
|
||
const decline = [];
|
||
for (const c of candidates) {
|
||
(chosen.has(`seq-${c.decision_seq}`) ? promote : decline).push(c);
|
||
}
|
||
return { promote, decline };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the JS tests to verify they pass**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && npm test`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 5: Register the tool**
|
||
|
||
In `tht-gate.js`, immediately BEFORE the `pi.registerTool({ name: "rewrite_question", ...})` block, add:
|
||
|
||
```js
|
||
pi.registerTool({
|
||
name: "reviewer_memory_promote",
|
||
label: "Promozione memorie riusabili (reviewer)",
|
||
description:
|
||
"F8 (prima della chiusura di fase): propone al reviewer i candidati di promozione " +
|
||
"calcolati dalla CLI (tht memory promote --preview: tipi riusabili concept_clarified/" +
|
||
"table_promoted/table_excluded, max 5, esclusi i gia' promossi/rifiutati). Le selezioni " +
|
||
"vengono salvate nel vectordb (tht memory save-one) e registrate come memory_promoted; " +
|
||
"le deselezioni come memory_promotion_declined (non riproposte). Nessun parametro oltre " +
|
||
"alla sessione: i candidati sono deterministici, NON li scrivi tu.",
|
||
parameters: Type.Object({
|
||
session: Type.String(),
|
||
}),
|
||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||
lockActive = true;
|
||
const { session } = params;
|
||
const phase = phaseId(ctx, currentPhase(ctx, session));
|
||
let candidates;
|
||
try {
|
||
candidates = JSON.parse(
|
||
tht(ctx, ["memory", "promote", "--session", session, "--preview", "--json"]),
|
||
);
|
||
} catch (e) {
|
||
const msg = (e.stderr || e.message || String(e)).toString().trim();
|
||
return textResult(`Preview di promozione non disponibile: ${msg}`);
|
||
}
|
||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||
await ctx.ui.notify(
|
||
"Nessuna decisione riusabile da promuovere in memoria per questa sessione.",
|
||
"info",
|
||
);
|
||
return textResult(
|
||
"Nessun candidato di promozione: prosegui con la chiusura della sessione.",
|
||
);
|
||
}
|
||
const options = promotionOptions(candidates);
|
||
const widget = buildMultiselectRequest({
|
||
id: `u${Date.now()}`,
|
||
phase,
|
||
title: "Quali decisioni salvare nella memoria riutilizzabile?",
|
||
allowEmpty: true,
|
||
options,
|
||
selected: options.map((o) => o.id),
|
||
content: promotionContent(candidates),
|
||
});
|
||
const resp = await emitAndWait(ctx, widget);
|
||
if (resp.control === "freetext")
|
||
return textResult(`Altro (reviewer): ${resp.text}. Valuta e ripresenta il gate.`);
|
||
if (resp.control === "back")
|
||
return textResult("Il reviewer vuole tornare indietro.");
|
||
if (resp.control === "exit") return textResult("Il reviewer vuole uscire.");
|
||
const { promote, decline } = splitPromotionChoices(candidates, resp.choices);
|
||
let saved = 0;
|
||
for (const c of promote) {
|
||
const err = relayIfThtFails(
|
||
ctx,
|
||
["memory", "save-one", "--session", session,
|
||
"--decision", String(c.decision_seq), "--json"],
|
||
"",
|
||
);
|
||
if (err) return err;
|
||
const e2 = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||
type: "memory_promoted", subject: c.subject,
|
||
detail: `seq:${c.decision_seq}`, rationale: c.rationale || c.detail || "",
|
||
}), "");
|
||
if (e2) return e2;
|
||
saved++;
|
||
}
|
||
for (const c of decline) {
|
||
const err = relayIfThtFails(ctx, decisionAddArgs(session, {
|
||
type: "memory_promotion_declined", subject: c.subject,
|
||
detail: `seq:${c.decision_seq}`,
|
||
}), "");
|
||
if (err) return err;
|
||
}
|
||
return textResult(
|
||
`Promozione registrata: ${saved} memorie salvate nel vectordb, ` +
|
||
`${decline.length} candidati scartati.`,
|
||
);
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Extend the anti-bypass FORBIDDEN list**
|
||
|
||
In `tht-gate.js`, change:
|
||
```js
|
||
const FORBIDDEN = [
|
||
/\btht\s+phase\s+(advance|reopen)\b/,
|
||
/\btht\s+decision\s+add\b/,
|
||
/\btht\s+cte\s+plan\b/,
|
||
];
|
||
```
|
||
to:
|
||
```js
|
||
const FORBIDDEN = [
|
||
/\btht\s+phase\s+(advance|reopen)\b/,
|
||
/\btht\s+decision\s+add\b/,
|
||
/\btht\s+cte\s+plan\b/,
|
||
// La promozione in memoria passa dal reviewer (reviewer_memory_promote), mai da shell.
|
||
/\btht\s+memory\s+(promote|save-one)\b/,
|
||
];
|
||
```
|
||
(The gate's own calls use `execFileSync` directly and are not intercepted by the bash `tool_call` hook.)
|
||
|
||
- [ ] **Step 7: Run JS tests + the gate CLI signature integration test**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && npm test && .venv/bin/pytest tests/integration/test_gate_cli_signatures.py -v`
|
||
Expected: all PASS — the signature test auto-extracts the two new `["memory", ...]` call sites and verifies `--session/--preview/--json/--decision` exist on the CLI.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/extensions/tht-gate.js harness/.pi/extensions/gate/__tests__/gate_memory_promote.test.js
|
||
git commit -m "feat(gate): reviewer_memory_promote — deterministic F8 memory-promotion gate"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: SKILL.md — prescribe the promotion flow
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/skills/tht-sessione/SKILL.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: the `reviewer_memory_promote` tool (Task 3).
|
||
- Produces: the workflow contract the model follows. No code.
|
||
|
||
- [ ] **Step 1: Replace the Phase 2 D11 note (current step 5)**
|
||
|
||
Replace:
|
||
```markdown
|
||
5. **Promotion (D11).** To promote ONE memory in `profile=workstation`, use
|
||
`tht memory save-one` (targeted one-row upsert via the writer key). Batch
|
||
promotion (`tht memory promote`) is server-side. Memories live ONLY in the
|
||
vectordb (no local registry).
|
||
```
|
||
with:
|
||
```markdown
|
||
5. Memories are promoted at the END of the workflow (Phase 8, the
|
||
`reviewer_memory_promote` gate) — never promote from here, never run
|
||
`tht memory promote`/`save-one` yourself (the gate blocks them).
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the Phase 5 optional-promotion step**
|
||
|
||
Replace:
|
||
```markdown
|
||
3. **Memory promotion (F5).** If you want to promote memories from this session, use
|
||
`tht memory save-one` (workstation) or `tht memory promote` (server). Memories
|
||
live ONLY in the vectordb (no local registry).
|
||
4. Close with `reviewer_confirm kind:"phase"`.
|
||
```
|
||
with:
|
||
```markdown
|
||
3. Close with `reviewer_confirm kind:"phase"`.
|
||
```
|
||
|
||
- [ ] **Step 3: Rewrite Phase 8 to include the promotion gate**
|
||
|
||
Replace:
|
||
```markdown
|
||
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no).
|
||
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell
|
||
the reviewer that dbt generation is not implemented yet.
|
||
3. If no: close with `reviewer_confirm kind:"phase"`. The session is finalizable.
|
||
```
|
||
with:
|
||
```markdown
|
||
1. Ask the reviewer whether they want a datamart (`reviewer_select` yes/no).
|
||
2. If yes: `tht datamart generate` (stub — raises NotImplementedError for now). Tell
|
||
the reviewer that dbt generation is not implemented yet.
|
||
3. **Memory promotion.** Call `reviewer_memory_promote` with ONLY the session id: the
|
||
gate computes the candidates itself (`tht memory promote --preview` — the 3
|
||
reusable types, already excluding promoted/declined ones) and shows the reviewer a
|
||
pre-selected checklist. Selected → saved to the vectordb + `memory_promoted`;
|
||
deselected → `memory_promotion_declined` (never re-proposed). If the gate reports
|
||
zero candidates, move on — do not retry.
|
||
4. Close with `reviewer_confirm kind:"phase"`. The session is finalizable.
|
||
```
|
||
|
||
- [ ] **Step 4: Verify the file renders coherently**
|
||
|
||
Run: `grep -n "reviewer_memory_promote\|Promotion (D11)\|Memory promotion (F5)" /Users/mp/projects/ThothII/harness/.pi/skills/tht-sessione/SKILL.md`
|
||
Expected: `reviewer_memory_promote` appears in F2's step 5 and Phase 8; the two old notes are gone.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/skills/tht-sessione/SKILL.md
|
||
git commit -m "docs(skill): prescribe the F8 memory-promotion gate; drop optional D11 notes"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: `solved_question` kind + `tht/solved.py`
|
||
|
||
**Files:**
|
||
- Create: `harness/tht/solved.py`
|
||
- Modify: `harness/tht/vectorstore/reader.py` (KIND_TO_TABLE)
|
||
- Modify: `harness/tht/vectorstore/rest_writer.py` (TABLE_TO_KINDS)
|
||
- Test: `harness/tests/test_solved_question.py` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `VectorRecord`, `content_hash`, `pack_metadata`, `VectorRestClient.existing_hashes(table, kinds)` / `.upsert_records(table, rows)` (all existing).
|
||
- Produces: `SOLVED_KIND = "solved_question"`; `solved_question_record(*, session_id, question, sql, tables) -> VectorRecord` (id `solved:<session_id>`, content = question); `save_solved_question(record, *, writer, embedder) -> int`; `_solved_hash(record) -> str`. Consumed by Task 6.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `harness/tests/test_solved_question.py`:
|
||
|
||
```python
|
||
"""L1: coppia domanda->SQL risolta (kind solved_question) — memoria attiva parte B.
|
||
|
||
Una sessione finalizzata produce UN record nel vectordb (tabella `memory`, kind
|
||
dedicato): embedding = domanda riscritta, metadata = {question, sql, tables,
|
||
session_id}. Upsert one-row stile D11 (mai sync: il suo delete-stale cancellerebbe
|
||
i record delle altre sessioni). L'hash di dedup copre domanda+SQL, cosi' un
|
||
re-finalize che cambia solo l'SQL aggiorna comunque la riga.
|
||
"""
|
||
from unittest.mock import MagicMock
|
||
|
||
from tht.solved import (
|
||
SOLVED_KIND,
|
||
_solved_hash,
|
||
save_solved_question,
|
||
solved_question_record,
|
||
)
|
||
from tht.vectorstore.reader import tables_for_kinds
|
||
|
||
|
||
def _rec(**kw):
|
||
base = dict(
|
||
session_id="s1", question="quante ablazioni nel 2023",
|
||
sql="SELECT count(*) FROM fact_seeablazione", tables=["fact_seeablazione"],
|
||
)
|
||
base.update(kw)
|
||
return solved_question_record(**base)
|
||
|
||
|
||
def test_record_shape():
|
||
r = _rec()
|
||
assert r.id == "solved:s1"
|
||
assert r.kind == SOLVED_KIND
|
||
assert r.content == "quante ablazioni nel 2023" # embedding = solo la domanda
|
||
assert r.metadata["sql"].startswith("SELECT")
|
||
assert r.metadata["tables"] == ["fact_seeablazione"]
|
||
assert r.metadata["session_id"] == "s1"
|
||
|
||
|
||
def test_solved_kind_maps_to_memory_table():
|
||
assert tables_for_kinds([SOLVED_KIND]) == ["memory"]
|
||
|
||
|
||
def test_save_upserts_single_row_into_memory_table():
|
||
writer = MagicMock()
|
||
writer.existing_hashes.return_value = {}
|
||
writer.upsert_records.return_value = 1
|
||
embedder = MagicMock()
|
||
embedder.embed_documents.return_value = [[0.1] * 8]
|
||
|
||
assert save_solved_question(_rec(), writer=writer, embedder=embedder) == 1
|
||
writer.sync.assert_not_called()
|
||
table, rows = writer.upsert_records.call_args[0]
|
||
assert table == "memory"
|
||
assert len(rows) == 1
|
||
assert rows[0]["record_key"] == "solved:s1"
|
||
assert rows[0]["metadata"]["kind"] == SOLVED_KIND
|
||
assert rows[0]["metadata"]["sql"].startswith("SELECT")
|
||
|
||
|
||
def test_save_skips_when_question_and_sql_unchanged():
|
||
r = _rec()
|
||
writer = MagicMock()
|
||
writer.existing_hashes.return_value = {r.id: _solved_hash(r)}
|
||
embedder = MagicMock()
|
||
assert save_solved_question(r, writer=writer, embedder=embedder) == 0
|
||
embedder.embed_documents.assert_not_called()
|
||
writer.upsert_records.assert_not_called()
|
||
|
||
|
||
def test_sql_change_alone_triggers_reupsert():
|
||
old = _rec()
|
||
new = _rec(sql="SELECT 1") # stessa domanda, SQL diverso
|
||
writer = MagicMock()
|
||
writer.existing_hashes.return_value = {old.id: _solved_hash(old)}
|
||
writer.upsert_records.return_value = 1
|
||
embedder = MagicMock()
|
||
embedder.embed_documents.return_value = [[0.0] * 4]
|
||
assert save_solved_question(new, writer=writer, embedder=embedder) == 1
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_question.py -v`
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'tht.solved'`.
|
||
|
||
- [ ] **Step 3: Add the kind mappings**
|
||
|
||
In `harness/tht/vectorstore/reader.py`, change `KIND_TO_TABLE` to:
|
||
```python
|
||
KIND_TO_TABLE = {
|
||
"schema_table": "schema_records",
|
||
"schema_column": "schema_records",
|
||
"evidence": "evidence",
|
||
"memory": "memory",
|
||
"solved_question": "memory", # coppie domanda->SQL: stessa tabella, kind dedicato
|
||
}
|
||
```
|
||
In `harness/tht/vectorstore/rest_writer.py`, change `TABLE_TO_KINDS` to:
|
||
```python
|
||
TABLE_TO_KINDS = {
|
||
"schema_records": {"schema_table", "schema_column"},
|
||
"evidence": {"evidence"},
|
||
"memory": {"memory", "solved_question"},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `harness/tht/solved.py`**
|
||
|
||
```python
|
||
"""Coppie domanda->SQL risolte (kind `solved_question`) — memoria attiva, parte B.
|
||
|
||
Una sessione finalizzata produce UN record nel vectordb: l'embedding e' la domanda
|
||
riscritta (content), il metadata porta l'SQL finale e le tabelle promosse. Vive
|
||
nella tabella pgvector `memory` con kind dedicato (nessuna DDL server-side); si
|
||
consulta nelle fasi F4/F6/F7 con `tht memory solved-search` come materiale di
|
||
riferimento (exemplar), NON come decisione da ri-applicare.
|
||
|
||
Scrittura: SOLO upsert one-row stile D11 (`save_solved_question`). Questi record
|
||
non passano MAI da `VectorStore.sync`/`RestVectorWriter.sync`: il passo
|
||
delete-stale del sync, ricevendo il solo record corrente, cancellerebbe le coppie
|
||
delle altre sessioni. Per lo stesso motivo l'hash di dedup e' calcolato qui
|
||
(domanda+SQL) e non dal solo content come fa il sync.
|
||
"""
|
||
from tht.vectorstore.records import VectorRecord
|
||
|
||
SOLVED_KIND = "solved_question"
|
||
|
||
|
||
def solved_question_record(
|
||
*, session_id: str, question: str, sql: str, tables: list[str]
|
||
) -> VectorRecord:
|
||
return VectorRecord(
|
||
id=f"solved:{session_id}",
|
||
kind=SOLVED_KIND,
|
||
ref=session_id,
|
||
title=question[:120],
|
||
content=question,
|
||
metadata={
|
||
"question": question,
|
||
"sql": sql,
|
||
"tables": tables,
|
||
"session_id": session_id,
|
||
},
|
||
)
|
||
|
||
|
||
def _solved_hash(record: VectorRecord) -> str:
|
||
# La domanda e' l'embedding (content); l'SQL vive solo nel metadata. L'hash
|
||
# copre entrambi: un re-finalize che cambia solo l'SQL aggiorna la riga.
|
||
from tht.vectorstore.store import content_hash
|
||
|
||
return content_hash(record.content + "\n" + str(record.metadata.get("sql", "")))
|
||
|
||
|
||
def save_solved_question(record: VectorRecord, *, writer, embedder) -> int:
|
||
"""Upsert one-row della coppia domanda->SQL via writer key (stesso pattern di
|
||
save_one_memory, spec D11): hash dedup client-side, embedding solo se domanda
|
||
o SQL sono cambiati. `writer` e' un VectorRestClient (writer key). Ritorna il
|
||
numero di righe upsertate (0 = invariata)."""
|
||
from tht.vectorstore.rest_writer import pack_metadata
|
||
|
||
new_hash = _solved_hash(record)
|
||
existing = writer.existing_hashes("memory", [SOLVED_KIND])
|
||
if existing.get(record.id) == new_hash:
|
||
return 0
|
||
embedding = embedder.embed_documents([record.content])[0]
|
||
return writer.upsert_records("memory", [{
|
||
"record_key": record.id,
|
||
"kind": record.kind,
|
||
"content_hash": new_hash,
|
||
"metadata": pack_metadata(record),
|
||
"embedding": embedding,
|
||
}])
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_question.py tests/test_vector_dual_key.py -v`
|
||
Expected: all PASS (dual-key tests confirm the mapping change breaks nothing).
|
||
|
||
- [ ] **Step 6: Full suite + lint, then commit**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .`
|
||
|
||
```bash
|
||
git add harness/tht/solved.py harness/tht/vectorstore/reader.py harness/tht/vectorstore/rest_writer.py harness/tests/test_solved_question.py
|
||
git commit -m "feat(solved): solved_question vector kind + one-row upsert (D11 pattern)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: CLI — `tht memory solved-index` / `solved-search`
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/solved.py` (add `build_solved_record` + `SolvedIndexError`)
|
||
- Modify: `harness/tht/cli/memory_cmd.py` (two commands + `index_solved_session`)
|
||
- Test: `harness/tests/test_solved_build.py` (create)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `question_context` (Task 2), `solved_question_record`/`save_solved_question` (Task 5), `effective_decisions` (`tht/phase.py`), `promoted_tables_for(cfg, session_id) -> set[str] | None` (`tht/cli/sql_cmd.py:42`), `has_vector_write_rest`/`require_vector_write_allowed` (`tht/cli/_guards.py`), `make_embedder`/`open_searcher`/`require_vector_cfg` (`tht/cli/vector_cmd.py`), `load_session_or_exit`/`session_dir` (already imported at the top of `memory_cmd.py`).
|
||
- Produces: `build_solved_record(session_dir, manifest, promoted_tables) -> VectorRecord` (raises `SolvedIndexError`); `index_solved_session(cfg, session_id) -> int` (raises `RuntimeError` when the writer key is missing — the finalize hook, Task 7, degrades it to a warning); CLI `tht memory solved-index <session_id> [--json]` and `tht memory solved-search "<q>" [--top N] [--json]`.
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `harness/tests/test_solved_build.py`:
|
||
|
||
```python
|
||
"""L1: build del record solved_question dagli artefatti persistiti della sessione.
|
||
|
||
Il record si costruisce SOLO da cio' che il workflow ha approvato: sql_final.sql
|
||
presente + decisione sql_approved nella vista effective; la domanda e' l'ultima
|
||
question_rewritten (fallback: la domanda del manifest)."""
|
||
from datetime import datetime
|
||
|
||
import pytest
|
||
|
||
from tht.decisions import append_decision
|
||
from tht.session.models import SessionManifest
|
||
from tht.solved import SolvedIndexError, build_solved_record
|
||
|
||
|
||
def _manifest() -> SessionManifest:
|
||
return SessionManifest(
|
||
id="s1", created_at=datetime(2026, 1, 1), question="domanda originale",
|
||
database="db", schema="public",
|
||
)
|
||
|
||
|
||
def test_build_uses_rewritten_question_sql_and_tables(tmp_path):
|
||
(tmp_path / "sql_final.sql").write_text("SELECT 1\n")
|
||
append_decision(tmp_path, type="question_rewritten", subject="domanda",
|
||
detail="domanda riscritta esplicita")
|
||
append_decision(tmp_path, type="sql_approved", subject="phase:7")
|
||
rec = build_solved_record(tmp_path, _manifest(), {"fact_x", "dim_y"})
|
||
assert rec.id == "solved:s1"
|
||
assert rec.content == "domanda riscritta esplicita"
|
||
assert rec.metadata["sql"] == "SELECT 1"
|
||
assert rec.metadata["tables"] == ["dim_y", "fact_x"] # ordinate
|
||
|
||
|
||
def test_build_falls_back_to_manifest_question(tmp_path):
|
||
(tmp_path / "sql_final.sql").write_text("SELECT 1")
|
||
append_decision(tmp_path, type="sql_approved", subject="phase:7")
|
||
rec = build_solved_record(tmp_path, _manifest(), None)
|
||
assert rec.content == "domanda originale"
|
||
assert rec.metadata["tables"] == []
|
||
|
||
|
||
def test_build_requires_sql_file(tmp_path):
|
||
append_decision(tmp_path, type="sql_approved", subject="phase:7")
|
||
with pytest.raises(SolvedIndexError, match="sql_final.sql"):
|
||
build_solved_record(tmp_path, _manifest(), None)
|
||
|
||
|
||
def test_build_requires_sql_approved(tmp_path):
|
||
(tmp_path / "sql_final.sql").write_text("SELECT 1")
|
||
with pytest.raises(SolvedIndexError, match="sql_approved"):
|
||
build_solved_record(tmp_path, _manifest(), None)
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify they fail**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_build.py -v`
|
||
Expected: FAIL with `ImportError: cannot import name 'build_solved_record'`.
|
||
|
||
- [ ] **Step 3: Add `build_solved_record` to `harness/tht/solved.py`**
|
||
|
||
Append:
|
||
|
||
```python
|
||
class SolvedIndexError(Exception):
|
||
"""La sessione non ha (ancora) gli artefatti per il record solved_question."""
|
||
|
||
|
||
def build_solved_record(session_dir, manifest, promoted_tables) -> VectorRecord:
|
||
"""Costruisce il record dagli artefatti persistiti (vista effective D15):
|
||
richiede sql_final.sql e la decisione sql_approved; la domanda e' l'ultima
|
||
question_rewritten, fallback la domanda del manifest."""
|
||
from tht.memory import question_context
|
||
from tht.phase import effective_decisions
|
||
|
||
sql_file = session_dir / "sql_final.sql"
|
||
if not sql_file.exists():
|
||
raise SolvedIndexError("sql_final.sql assente")
|
||
decisions = effective_decisions(session_dir)
|
||
if not any(d.type == "sql_approved" for d in decisions):
|
||
raise SolvedIndexError("decisione sql_approved assente")
|
||
return solved_question_record(
|
||
session_id=manifest.id,
|
||
question=question_context(decisions, manifest),
|
||
sql=sql_file.read_text().strip(),
|
||
tables=sorted(promoted_tables or set()),
|
||
)
|
||
```
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest tests/test_solved_build.py -v`
|
||
Expected: 4 PASS.
|
||
|
||
- [ ] **Step 4: Add the orchestration helper + the two commands to `memory_cmd.py`**
|
||
|
||
Append at the end of `harness/tht/cli/memory_cmd.py`:
|
||
|
||
```python
|
||
def index_solved_session(cfg, session_id: str) -> int:
|
||
"""Indicizza la coppia domanda->SQL della sessione (kind solved_question).
|
||
|
||
Solleva RuntimeError se manca la writer key e SolvedIndexError se mancano gli
|
||
artefatti: il finalize li degrada a warning, il comando CLI li converte in
|
||
errori espliciti."""
|
||
from tht.cli.sql_cmd import promoted_tables_for
|
||
from tht.cli.vector_cmd import make_embedder
|
||
from tht.solved import build_solved_record, save_solved_question
|
||
from tht.vectorstore.rest_client import VectorRestClient
|
||
|
||
if not has_vector_write_rest(cfg):
|
||
raise RuntimeError(
|
||
"vector_write_rest assente: la coppia domanda->SQL si indicizza con la "
|
||
"writer key (workstation) o dal server"
|
||
)
|
||
manifest = load_session_or_exit(cfg, session_id)
|
||
record = build_solved_record(
|
||
session_dir(cfg, session_id), manifest, promoted_tables_for(cfg, session_id)
|
||
)
|
||
return save_solved_question(
|
||
record,
|
||
writer=VectorRestClient(cfg.vector_write_rest),
|
||
embedder=make_embedder(cfg.embeddings),
|
||
)
|
||
|
||
|
||
@memory_app.command("solved-index")
|
||
def solved_index_cmd(
|
||
session_id: str = typer.Argument(..., help="Id sessione con sql_final.sql approvato."),
|
||
json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
"""Indicizza la coppia domanda->SQL nel vectordb (backfill; il finalize lo fa da solo)."""
|
||
import json as _json
|
||
|
||
from tht.solved import SolvedIndexError
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
require_vector_write_allowed(cfg, "memory solved-index")
|
||
try:
|
||
count = index_solved_session(cfg, session_id)
|
||
except RuntimeError as e:
|
||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||
raise typer.Exit(code=4)
|
||
except SolvedIndexError as e:
|
||
typer.secho(f"ERRORE: sessione {session_id} non indicizzabile: {e}",
|
||
fg=typer.colors.RED, err=True)
|
||
raise typer.Exit(code=3)
|
||
msg = (
|
||
f"1 coppia domanda->SQL indicizzata (solved:{session_id})."
|
||
if count else "Nessun upsert: coppia gia' aggiornata."
|
||
)
|
||
if json_out:
|
||
typer.echo(_json.dumps({"upserted": count, "id": f"solved:{session_id}"},
|
||
ensure_ascii=False))
|
||
return
|
||
typer.secho(f"OK: {msg}", fg=typer.colors.GREEN)
|
||
|
||
|
||
@memory_app.command("solved-search")
|
||
def solved_search_cmd(
|
||
question: str = typer.Argument(..., help="Domanda da confrontare con quelle risolte."),
|
||
top: int = typer.Option(3, "--top"),
|
||
json_out: bool = typer.Option(False, "--json", help="Output JSON (per Pi)."),
|
||
config: Path = CONFIG_OPT,
|
||
) -> None:
|
||
"""Domande gia' risolte simili (kind solved_question): domanda, SQL e tabelle."""
|
||
from rich.console import Console
|
||
from rich.table import Table
|
||
|
||
from tht.cli.vector_cmd import make_embedder, open_searcher
|
||
from tht.solved import SOLVED_KIND
|
||
|
||
cfg = _load_config_or_exit(config)
|
||
require_vector_cfg(cfg)
|
||
searcher = open_searcher(cfg)
|
||
embedder = make_embedder(cfg.embeddings)
|
||
hits = searcher.search(embedder.embed_query(question), top_n=top, kinds=[SOLVED_KIND])
|
||
results = [
|
||
{
|
||
"session_id": h.metadata.get("session_id", h.ref),
|
||
"question": h.metadata.get("question", h.content),
|
||
"sql": h.metadata.get("sql", ""),
|
||
"tables": h.metadata.get("tables", []),
|
||
"score": round(h.similarity, 4),
|
||
}
|
||
for h in hits
|
||
]
|
||
if json_out:
|
||
typer.echo(json.dumps(results, ensure_ascii=False, indent=2))
|
||
return
|
||
if not results:
|
||
typer.secho("Nessuna domanda risolta simile.", fg=typer.colors.YELLOW)
|
||
return
|
||
table = Table(title=f"Domande risolte simili a: {question}")
|
||
table.add_column("Sessione")
|
||
table.add_column("Domanda")
|
||
table.add_column("Tabelle")
|
||
table.add_column("Score", justify="right")
|
||
for r in results:
|
||
table.add_row(r["session_id"], r["question"][:60],
|
||
", ".join(r["tables"]), f"{r['score']:.3f}")
|
||
Console().print(table)
|
||
```
|
||
|
||
(`json` and `require_vector_cfg` are already module-level imports in `memory_cmd.py` — `solved_search_cmd` uses them directly; only `make_embedder`/`open_searcher` are imported lazily, matching the file's existing pattern.)
|
||
|
||
- [ ] **Step 5: Verify CLI wiring by help text**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/tht memory solved-index --help && .venv/bin/tht memory solved-search --help`
|
||
Expected: both exit 0 and show `--json` (and `--top` on solved-search).
|
||
|
||
- [ ] **Step 6: Full suite + lint, then commit**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && .venv/bin/ruff check .`
|
||
Expected: all pass.
|
||
|
||
```bash
|
||
git add harness/tht/solved.py harness/tht/cli/memory_cmd.py harness/tests/test_solved_build.py
|
||
git commit -m "feat(cli): tht memory solved-index / solved-search (question->SQL exemplars)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Finalize hook (best-effort indexing)
|
||
|
||
**Files:**
|
||
- Modify: `harness/tht/cli/session_cmd.py` (`finalize_cmd`, after the manifest write)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `index_solved_session(cfg, session_id)` (Task 6). Lazy import inside the function (memory_cmd imports FROM session_cmd — a top-level import back would be circular; finalize already uses lazy imports throughout).
|
||
- Produces: `tht session finalize` indexes the pair automatically; any failure prints a yellow warning and finalize still succeeds.
|
||
|
||
- [ ] **Step 1: Add the hook**
|
||
|
||
In `harness/tht/cli/session_cmd.py`, inside `finalize_cmd`, locate:
|
||
|
||
```python
|
||
manifest.status = "finalized"
|
||
manifest.updated_at = datetime.now(UTC)
|
||
manifest.updated_by = current_author()
|
||
manifest.to_yaml(sdir / MANIFEST)
|
||
```
|
||
|
||
and insert immediately AFTER it (before the final `typer.secho(f"OK: sessione ...")`):
|
||
|
||
```python
|
||
# --- memoria attiva (parte B): indicizza la coppia domanda->SQL, best-effort ---
|
||
# Import lazy: memory_cmd importa da session_cmd (un import top-level qui sarebbe
|
||
# circolare). Qualunque errore (writer key assente, VPN giu', Ollama spento) NON
|
||
# deve bloccare il finalize: l'indice e' derivato e recuperabile con
|
||
# `tht memory solved-index <id>`.
|
||
try:
|
||
from tht.cli.memory_cmd import index_solved_session
|
||
|
||
if index_solved_session(cfg, session_id):
|
||
typer.secho(
|
||
"OK: coppia domanda->SQL indicizzata nel vectordb (solved_question).",
|
||
fg=typer.colors.GREEN,
|
||
)
|
||
except Exception as e:
|
||
typer.secho(
|
||
f"ATTENZIONE: coppia domanda->SQL non indicizzata ({e}). "
|
||
f"Recupera con `tht memory solved-index {session_id}`.",
|
||
fg=typer.colors.YELLOW, err=True,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Verify no import cycle and no regressions**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/python -c "import tht.cli" && .venv/bin/pytest -q && .venv/bin/ruff check .`
|
||
Expected: import OK, all tests pass, no lint errors.
|
||
|
||
(The full finalize path needs the real DWH + writer key: it is exercised at L2 during the live gate below, not here.)
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add harness/tht/cli/session_cmd.py
|
||
git commit -m "feat(finalize): auto-index the question->SQL pair (best-effort, never blocks)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: SKILL.md recall (F4/F6/F7 + Session end) + docs
|
||
|
||
**Files:**
|
||
- Modify: `harness/.pi/skills/tht-sessione/SKILL.md`
|
||
- Modify: `PROJECT_STATE.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `tht memory solved-search` (Task 6), the finalize hook (Task 7).
|
||
|
||
- [ ] **Step 1: Phase 4 — add the exemplar consultation to step 1**
|
||
|
||
In the Phase 4 section, replace:
|
||
```markdown
|
||
1. `tht schema introspect` + `tht schema render --format mschema-text` for the schema
|
||
context. Copy table/column names EXACTLY from it — never invent objects.
|
||
```
|
||
with:
|
||
```markdown
|
||
1. `tht schema introspect` + `tht schema render --format mschema-text` for the schema
|
||
context. Copy table/column names EXACTLY from it — never invent objects.
|
||
Also run `tht memory solved-search "<question>" --json`: similar already-solved
|
||
questions show which tables comparable questions used. Cite relevant precedents
|
||
(session id + tables) to the reviewer as CONTEXT — they are reference material,
|
||
NOT decisions to apply; their filters/periods may not transfer.
|
||
```
|
||
|
||
- [ ] **Step 2: Phase 6 — add the exemplar consultation to step 1**
|
||
|
||
Replace:
|
||
```markdown
|
||
1. Read `cte.md`. Decompose the rewritten question into CTEs (Agent View Generation):
|
||
each CTE captures an informative subset with a clear purpose, named in snake_case.
|
||
```
|
||
with:
|
||
```markdown
|
||
1. Read `cte.md`. Decompose the rewritten question into CTEs (Agent View Generation):
|
||
each CTE captures an informative subset with a clear purpose, named in snake_case.
|
||
`tht memory solved-search "<question>" --json` shows how similar solved questions
|
||
were structured — use as reference only.
|
||
```
|
||
|
||
- [ ] **Step 3: Phase 7 — add the exemplar consultation to step 1**
|
||
|
||
Replace:
|
||
```markdown
|
||
1. Read `sql-generation.md`. Recursive divide-and-conquer: the CTEs approved in
|
||
Phase 6 are the preferred building blocks (reuse them by name).
|
||
```
|
||
with:
|
||
```markdown
|
||
1. Read `sql-generation.md`. Recursive divide-and-conquer: the CTEs approved in
|
||
Phase 6 are the preferred building blocks (reuse them by name).
|
||
`tht memory solved-search "<question>" --json` gives the final SQL of similar
|
||
solved questions: reference exemplars — never copy filters, periods or
|
||
populations without checking them against the current rewritten question.
|
||
```
|
||
|
||
- [ ] **Step 4: Session end — document the auto-indexing**
|
||
|
||
Replace:
|
||
```markdown
|
||
When the workflow is complete (Phase 8), `tht session finalize` closes the session
|
||
and unlocks input. The persisted state (ledger `review_decisions.jsonl` + artifacts)
|
||
is the truth: what is not recorded did not happen.
|
||
```
|
||
with:
|
||
```markdown
|
||
When the workflow is complete (Phase 8), `tht session finalize` closes the session
|
||
and unlocks input. Finalize also indexes the question→SQL pair in the vectordb
|
||
(kind `solved_question`, best-effort — on failure recover with `tht memory
|
||
solved-index <id>`). The persisted state (ledger `review_decisions.jsonl` +
|
||
artifacts) is the truth: what is not recorded did not happen.
|
||
```
|
||
|
||
- [ ] **Step 5: Update `PROJECT_STATE.md`**
|
||
|
||
Add a dated entry (match the file's existing style) summarizing: active memory shipped — F8 promotion gate (`reviewer_memory_promote`, decision types `memory_promoted`/`memory_promotion_declined`, declined-filter) + solved-question exemplars (`solved_question` kind in the `memory` table, `tht memory solved-index/solved-search`, finalize hook, recall prescribed in F4/F6/F7). Note the pending L2 gate: one live end-to-end session on workspace `psd` to verify (a) the promotion widget renders and persists, (b) finalize indexes the pair, (c) `solved-search` returns it.
|
||
|
||
- [ ] **Step 6: Final verification sweep**
|
||
|
||
Run: `cd /Users/mp/projects/ThothII/harness && .venv/bin/pytest -q && npm test && .venv/bin/ruff check .`
|
||
Expected: everything green.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add harness/.pi/skills/tht-sessione/SKILL.md PROJECT_STATE.md
|
||
git commit -m "docs(skill): prescribe solved-question recall in F4/F6/F7; refresh PROJECT_STATE"
|
||
```
|
||
|
||
---
|
||
|
||
## Manual L2 gate (after all tasks — human-run, VPN + writer key required)
|
||
|
||
Not automatable in CI (real GLM + remote pgvector). Run one full session on workspace `psd` via `./scripts/run-stack.sh`:
|
||
|
||
1. Complete a question through F8: at step 3 the promotion checklist must appear pre-selected; deselect one candidate, approve.
|
||
2. Verify the ledger: `tht decision list --session <id>` shows `memory_promoted` (selected) and `memory_promotion_declined` (deselected, `detail seq:<n>`).
|
||
3. Re-open the gate scenario (new session on a similar question): F2 must retrieve the promoted memory; the declined one must not resurface at a re-run of the promotion preview.
|
||
4. `tht session finalize <id>` prints the green solved-question line; `tht memory solved-search "<the question>" --json` returns the pair with sql + tables.
|