Files
ThothII/harness/tht/session/store.py
T

303 lines
12 KiB
Python

import json
import os
import shutil
from datetime import UTC, datetime
from pathlib import Path
import yake
from tht.config import DatabaseConfig
from tht.session.models import SessionManifest
from tht.textutil import slugify
MANIFEST = "session_manifest.yaml"
MAX_SLUG_CHARS = 40
MAX_SUMMARY_CHARS = 120
MAX_NAME_CHARS = 60
MAX_NAME_WORDS = 5
# Generic query verbs/words dropped from a derived session name (lowercased compare);
# they carry no content and would just crowd out the salient keywords.
_STOPNAME = {
"crea", "creare", "elenca", "elencare", "elencami", "mostra", "mostrami", "mostrare",
"dammi", "dai", "trova", "trovami", "cerca", "cercami", "voglio", "vorrei", "fammi",
"quanti", "quante", "quanto", "quanta", "conta", "numero", "lista", "elenco", "vedi",
"visualizza", "ottieni", "restituisci", "calcola", "esponi", "riporta", "fornisci",
}
class SessionError(Exception):
pass
def current_author() -> str:
"""Autore della sessione (D6). Nel profilo `none` dell'MVP l'operatore e' sulla
propria macchina: default `dev@local`. Con auth reale il backend passa l'id via
THT_AUTHOR. Una sola fonte per author/updated_by."""
return os.environ.get("THT_AUTHOR", "dev@local").strip() or "dev@local"
def _summarize(question: str) -> str:
"""Domanda sintetica per il manifest/la sidebar (D6 §5.2): prima riga, troncata."""
first = question.strip().splitlines()[0].strip() if question.strip() else ""
return first[:MAX_SUMMARY_CHARS]
def _extract_name(question: str) -> str:
"""Nome-sintesi (3-5 parole-chiave) della domanda per la sidebar, senza LLM (YAKE).
Le parole-chiave restano nella lingua della domanda (è contenuto, non chrome UI).
Ripiega su `_summarize` se YAKE non è disponibile o non estrae nulla di utile."""
q = (question or "").strip()
if not q:
return ""
try:
extractor = yake.KeywordExtractor(lan="it", n=1, top=8, dedupLim=0.9)
ranked = [k for k, _ in extractor.extract_keywords(q)]
except Exception:
return _summarize(question)
seen: set[str] = set()
picked: list[str] = []
for k in ranked:
kl = k.lower()
if kl in _STOPNAME or kl in seen:
continue
seen.add(kl)
picked.append(k)
if len(picked) >= MAX_NAME_WORDS:
break
if not picked:
return _summarize(question)
ql = q.lower()
picked.sort(key=lambda k: ql.find(k.lower())) # natural reading order
name = " ".join(picked)
return (name[0].upper() + name[1:])[:MAX_NAME_CHARS]
def render_question_md(question: str, assumptions: list[str] | None = None) -> str:
"""Rende question.md in modo deterministico: domanda + assunzioni opzionali.
Unica fonte di formattazione per question.md (riusata da create_session e
set_question), così il gate non deve costruire markdown a mano.
"""
body = f"# Domanda\n\n{question.strip()}\n"
items = [a.strip() for a in (assumptions or []) if a.strip()]
if items:
body += "\n## Assunzioni\n\n" + "".join(f"- {a}\n" for a in items)
return body
def _new_id(question: str, sessions_root: Path, stamp: str) -> str:
base = f"{stamp}-{slugify(question)[:MAX_SLUG_CHARS].rstrip('-')}"
candidate, n = base, 1
while (sessions_root / candidate).exists():
n += 1
candidate = f"{base}-{n}"
return candidate
def create_session(
question: str, db: DatabaseConfig, sessions_root: Path,
*, author: str | None = None, summary: str | None = None,
provider: str | None = None, model: str | None = None,
thinking: str | None = None, name: str | None = None,
) -> SessionManifest:
now = datetime.now(UTC)
# stamp con ora/min/sec: identifica univocamente sessioni dello stesso giorno
# sulla stessa domanda. Il contatore -n resta come rete per collisioni nello
# stesso secondo.
session_id = _new_id(question, sessions_root, now.strftime("%Y-%m-%d-%H%M%S"))
who = author or current_author()
# schema_version del workflow usato (D6/§5.3): consente di interpretare il ledger
# secondo la versione anche se il workflow evolve.
try:
from tht.workflow import load_workflow
schema_version = load_workflow().schema_version
except Exception:
schema_version = None
manifest = SessionManifest(
id=session_id, created_at=now, question=question,
database=db.database, schema=db.db_schema,
author=who, summary=summary or _summarize(question),
updated_at=now, updated_by=who, schema_version=schema_version,
provider=provider, model=model, thinking=thinking, name=name,
)
session_dir = sessions_root / session_id
manifest.to_yaml(session_dir / MANIFEST)
(session_dir / "question.md").write_text(render_question_md(question))
return manifest
def touch_manifest(
session_id: str, sessions_root: Path, *, updated_by: str | None = None
) -> SessionManifest:
"""Aggiorna updated_at/updated_by del manifest (D6 §5.2). Da chiamare ad ogni
mutazione della sessione (set-question, finalize, close)."""
manifest = load_session(session_id, sessions_root)
manifest.updated_at = datetime.now(UTC)
manifest.updated_by = updated_by or current_author()
manifest.to_yaml(sessions_root / session_id / MANIFEST)
return manifest
def set_question(
session_id: str,
question: str,
assumptions: list[str],
sessions_root: Path,
) -> Path:
"""Riscrive question.md (Fase 3) in modo deterministico, senza edit tool.
Valida l'esistenza della sessione (SessionError se assente) e ritorna il
path scritto. Non tocca il manifest né le decisioni.
"""
load_session(session_id, sessions_root)
path = sessions_root / session_id / "question.md"
path.write_text(render_question_md(question, assumptions))
touch_manifest(session_id, sessions_root)
return path
def set_schema_linking(
session_id: str,
data: dict,
sessions_root: Path,
) -> Path:
"""Valida e scrive schema_linking.json (Fase 4) in modo deterministico.
Valida `data` contro il modello SchemaLinking (ValidationError se invalido) PRIMA
di scrivere, così un artefatto malformato non tocca mai il disco. Ritorna il path.
"""
from tht.session.models import SchemaLinking
load_session(session_id, sessions_root)
model = SchemaLinking.model_validate(data)
path = sessions_root / session_id / "schema_linking.json"
path.write_text(
json.dumps(model.model_dump(by_alias=True), indent=2, ensure_ascii=False)
)
touch_manifest(session_id, sessions_root)
return path
def sync_schema_linking(session_id: str, sessions_root: Path) -> Path:
"""Project the effective F4 ledger decisions into schema_linking.json.
candidates/excluded are rebuilt from table_promoted/table_excluded +
column_promoted/column_excluded (last decision per subject wins). question,
joins, concept_formulas and open_questions are preserved from the existing
file when present. The reviewer's curation is thus authoritative and
deterministic (no model transcription)."""
from tht.phase import effective_decisions
session_dir = sessions_root / session_id
manifest = load_session(session_id, sessions_root)
existing: dict = {}
sl_path = session_dir / "schema_linking.json"
if sl_path.exists():
existing = json.loads(sl_path.read_text())
# last decision per subject wins (handles a re-run of the gate).
latest: dict[str, str] = {}
for d in effective_decisions(session_dir):
if d.type in ("table_promoted", "table_excluded", "column_promoted", "column_excluded"):
latest[d.subject] = d.type
candidates: list[dict] = []
excluded: list[dict] = []
for subject, dtype in latest.items():
is_column = "." in subject
kind = "column" if is_column else "table"
if dtype in ("table_promoted", "column_promoted"):
candidates.append({"kind": kind, "name": subject, "decision": "promoted"})
else:
excluded.append({"kind": kind, "name": subject})
data = {
"question": existing.get("question") or manifest.question,
"candidates": candidates,
"joins": existing.get("joins", []),
"excluded": excluded,
"open_questions": existing.get("open_questions", []),
"concept_formulas": existing.get("concept_formulas", []),
}
return set_schema_linking(session_id, data, sessions_root)
def load_session(session_id: str, sessions_root: Path) -> SessionManifest:
path = sessions_root / session_id / MANIFEST
if not path.exists():
raise SessionError(f"Sessione non trovata: {session_id} (atteso {path})")
return SessionManifest.from_yaml(path)
def close_session(session_id: str, sessions_root: Path) -> SessionManifest:
manifest = load_session(session_id, sessions_root)
manifest.status = "closed"
manifest.updated_at = datetime.now(UTC)
manifest.updated_by = current_author()
manifest.to_yaml(sessions_root / session_id / MANIFEST)
return manifest
def _save_touched(manifest: SessionManifest, sessions_root: Path) -> SessionManifest:
"""Persist `manifest` updating updated_at/updated_by (single save path for mutations)."""
manifest.updated_at = datetime.now(UTC)
manifest.updated_by = current_author()
manifest.to_yaml(sessions_root / manifest.id / MANIFEST)
return manifest
def set_name(session_id: str, name: str | None, sessions_root: Path) -> SessionManifest:
"""Set the descriptive name (empty/blank clears it back to None)."""
manifest = load_session(session_id, sessions_root)
manifest.name = (name or "").strip() or None
return _save_touched(manifest, sessions_root)
def set_group(session_id: str, group: str | None, sessions_root: Path) -> SessionManifest:
"""Set the group (empty/blank clears it back to None)."""
manifest = load_session(session_id, sessions_root)
manifest.group = (group or "").strip() or None
return _save_touched(manifest, sessions_root)
def set_archived(session_id: str, archived: bool, sessions_root: Path) -> SessionManifest:
"""Flip the archived flag. Archiving does NOT change resumability (a finalized
session stays read-only); unarchive only moves it back to the active list."""
manifest = load_session(session_id, sessions_root)
manifest.archived = archived
return _save_touched(manifest, sessions_root)
def delete_session(session_id: str, sessions_root: Path) -> None:
"""Hard-delete the session directory. SessionError if it does not exist."""
load_session(session_id, sessions_root) # raises SessionError if absent
shutil.rmtree(sessions_root / session_id)
def build_documents(manifest: SessionManifest, session_dir: Path) -> list[dict]:
"""Ordered, read-only document bundle for the UI panel. Only documents that exist
on disk are returned. CTE artifacts (F6) are intentionally excluded (intermediate)."""
docs: list[dict] = [{
"phase": "—", "key": "question", "title": "Original question",
"format": "text", "content": manifest.question,
}]
spec = [
("question.md", "F3", "revised_question", "Revised question", "markdown"),
("schema_linking.json", "F4", "schema_linking", "Schema linking", "schema-linking"),
("sql_final.sql", "F7", "sql", "Final SQL", "sql"),
("validation_report.md", "finalize", "validation_report", "Validation report", "markdown"),
("review_decisions.jsonl", "—", "decisions", "Decisions", "decisions"),
]
for filename, phase, key, title, fmt in spec:
path = session_dir / filename
if path.exists():
docs.append({
"phase": phase, "key": key, "title": title,
"format": fmt, "content": path.read_text(),
})
return docs