403 lines
16 KiB
Python
403 lines
16 KiB
Python
import json
|
|
import os
|
|
import shutil
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import yake
|
|
|
|
from tht.config import DatabaseConfig
|
|
from tht.session.models import SessionManifest
|
|
|
|
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:
|
|
"""Create an opaque UUIDv4 session identifier.
|
|
|
|
``question``, ``sessions_root`` and ``stamp`` remain accepted temporarily so
|
|
existing workflow callers do not need to change as the repository boundary
|
|
is introduced.
|
|
"""
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
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 new_session_manifest(
|
|
question: str, db: DatabaseConfig, *, provider=None, model=None, thinking=None, name=None
|
|
) -> SessionManifest:
|
|
"""Create an unsaved UUIDv4 manifest for a repository-owned session."""
|
|
now = datetime.now(UTC)
|
|
return SessionManifest(
|
|
id=str(uuid.uuid4()), created_at=now, question=question,
|
|
database=db.database, schema=db.db_schema, author=current_author(),
|
|
summary=_summarize(question), updated_at=now, updated_by=current_author(),
|
|
provider=provider, model=model, thinking=thinking, name=name,
|
|
)
|
|
|
|
|
|
def build_snapshot_documents(snapshot) -> list[dict]:
|
|
docs = [{"phase": "—", "key": "question", "title": "Original question", "format": "text", "content": snapshot.manifest.question}]
|
|
spec = [
|
|
("question", "F3", "revised_question", "Revised question", "markdown"),
|
|
("schema_linking", "F4", "schema_linking", "Schema linking", "schema-linking"),
|
|
("sql_final", "F7", "sql", "Final SQL", "sql"),
|
|
("validation_report", "finalize", "validation_report", "Validation report", "markdown"),
|
|
]
|
|
for artifact, phase, key, title, fmt in spec:
|
|
if artifact in snapshot.artifacts:
|
|
docs.append({"phase": phase, "key": key, "title": title, "format": fmt, "content": snapshot.artifacts[artifact]})
|
|
if snapshot.decisions:
|
|
docs.append({"phase": "—", "key": "decisions", "title": "Decisions", "format": "decisions", "content": "\n".join(d.model_dump_json() for d in snapshot.decisions) + "\n"})
|
|
return docs
|
|
|
|
|
|
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 sync_schema_linking_snapshot(snapshot) -> str:
|
|
"""Repository projection of effective F4 decisions into schema_linking JSON."""
|
|
from tht.phase import effective_decisions
|
|
|
|
existing = json.loads(snapshot.artifacts.get("schema_linking", "{}"))
|
|
latest: dict[str, str] = {}
|
|
for decision in effective_decisions(snapshot):
|
|
if decision.type in {"table_promoted", "table_excluded", "column_promoted", "column_excluded"}:
|
|
latest[decision.subject] = decision.type
|
|
candidates, excluded = [], []
|
|
for subject, kind in latest.items():
|
|
entity = "column" if "." in subject else "table"
|
|
if kind.endswith("promoted"):
|
|
candidates.append({"kind": entity, "name": subject, "decision": "promoted"})
|
|
else:
|
|
excluded.append({"kind": entity, "name": subject})
|
|
from tht.session.models import SchemaLinking
|
|
|
|
model = SchemaLinking.model_validate({
|
|
"question": existing.get("question") or snapshot.manifest.question,
|
|
"candidates": candidates, "excluded": excluded,
|
|
"joins": existing.get("joins", []), "open_questions": existing.get("open_questions", []),
|
|
"concept_formulas": existing.get("concept_formulas", []),
|
|
})
|
|
return json.dumps(model.model_dump(by_alias=True), indent=2, ensure_ascii=False)
|
|
|
|
|
|
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 fail_session(session_id: str, sessions_root: Path) -> SessionManifest:
|
|
"""Record a fatal managed-runtime failure without losing phase artifacts."""
|
|
manifest = load_session(session_id, sessions_root)
|
|
manifest.status = "failed"
|
|
manifest.updated_at = datetime.now(UTC)
|
|
manifest.updated_by = current_author()
|
|
manifest.to_yaml(sessions_root / session_id / MANIFEST)
|
|
return manifest
|
|
|
|
def reopen_session(session_id: str, sessions_root: Path) -> SessionManifest:
|
|
"""Mark a manually resumed session active again."""
|
|
manifest = load_session(session_id, sessions_root)
|
|
manifest.status = "open"
|
|
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 persist_verified_finalization(
|
|
repository,
|
|
session_id: str,
|
|
*,
|
|
validation_report: str,
|
|
evidence: str,
|
|
) -> SessionManifest:
|
|
"""Publish DWH-verified final artifacts and status at one repository boundary.
|
|
|
|
The caller must complete static validation, EXPLAIN and preview before this
|
|
function is entered. It intentionally does not touch solved-question
|
|
indexing: that derivative is best-effort and happens after the durable commit.
|
|
"""
|
|
snapshot = repository.get(session_id)
|
|
manifest = snapshot.manifest.model_copy(deep=True)
|
|
manifest.status = "finalized"
|
|
manifest.updated_at = datetime.now(UTC)
|
|
manifest.updated_by = current_author()
|
|
return repository.finalize(
|
|
manifest,
|
|
{"validation_report": validation_report, "evidence": evidence},
|
|
).manifest
|
|
|
|
|
|
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
|