Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -144,6 +144,7 @@ def _list_sessions(sessions_root: Path) -> list[dict]:
|
||||
"archived": m.archived,
|
||||
"workspace_id": m.workspace_id,
|
||||
"workspace_revision": m.workspace_revision,
|
||||
"interaction_language": m.interaction_language,
|
||||
})
|
||||
out.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return out
|
||||
@@ -162,7 +163,8 @@ def list_cmd(
|
||||
"updated_at": s.manifest.updated_at.isoformat() if s.manifest.updated_at else None,
|
||||
"author": s.manifest.author, "name": s.manifest.name, "group": s.manifest.group,
|
||||
"archived": s.manifest.archived, "workspace_id": s.manifest.workspace_id,
|
||||
"workspace_revision": s.manifest.workspace_revision}
|
||||
"workspace_revision": s.manifest.workspace_revision,
|
||||
"interaction_language": s.manifest.interaction_language}
|
||||
for s in session_repository(cfg).list()
|
||||
]
|
||||
if json_out:
|
||||
@@ -175,6 +177,9 @@ def list_cmd(
|
||||
@session_app.command("new")
|
||||
def new_cmd(
|
||||
question: str = typer.Argument(..., help="La domanda in linguaggio naturale."),
|
||||
interaction_language: str = typer.Option(
|
||||
None, "--interaction-language", help="BCP-47 interaction language; defaults to workspace language."
|
||||
),
|
||||
provider: str = typer.Option(None, "--provider", help="Provider LLM (es. zai, anthropic)."),
|
||||
model: str = typer.Option(None, "--model", help="Modello LLM (es. glm-5.2)."),
|
||||
thinking: str = typer.Option(None, "--thinking", help="Livello di thinking (es. medium)."),
|
||||
@@ -188,10 +193,18 @@ def new_cmd(
|
||||
from tht.session.store import _extract_name, new_session_manifest, render_question_md
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
from tht.session.language import validate_language_tag
|
||||
|
||||
try:
|
||||
language = validate_language_tag(
|
||||
interaction_language if interaction_language is not None else getattr(cfg, "language", "en")
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc), param_hint="--interaction-language") from None
|
||||
manifest = new_session_manifest(question, cfg.database,
|
||||
provider=provider, model=model, thinking=thinking,
|
||||
workspace_id=workspace_id, workspace_revision=workspace_revision,
|
||||
name=name or _extract_name(question))
|
||||
name=name or _extract_name(question), interaction_language=language)
|
||||
repository = session_repository(cfg)
|
||||
repository.create(manifest)
|
||||
repository.write_artifact(manifest.id, "question", render_question_md(question))
|
||||
@@ -306,6 +319,7 @@ def show_cmd(
|
||||
typer.echo(f"stato : {manifest.status}")
|
||||
typer.echo(f"domanda : {manifest.question}")
|
||||
typer.echo(f"target : {manifest.database} / {manifest.db_schema}")
|
||||
typer.echo(f"interaction_language: {manifest.interaction_language or '(legacy: not pinned)'}")
|
||||
typer.echo(f"decisioni: {len(decisions)}")
|
||||
for d in decisions[-10:]:
|
||||
typer.echo(f" [{d.seq}] {d.type}: {d.subject}" + (f" — {d.detail}" if d.detail else ""))
|
||||
@@ -352,11 +366,44 @@ def fail_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -
|
||||
session_repository(cfg).save_manifest(snapshot.manifest)
|
||||
typer.secho(f"OK: sessione {session_id} marcata failed.", fg=typer.colors.RED)
|
||||
|
||||
def ensure_session_language_or_exit(cfg, session_id: str):
|
||||
from tht.session.store import SessionError
|
||||
|
||||
try:
|
||||
return session_repository(cfg).ensure_interaction_language(
|
||||
session_id, getattr(cfg, "language", "en")
|
||||
).manifest
|
||||
except (SessionError, ValueError) as exc:
|
||||
typer.echo(f"ERROR: {exc}", err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
|
||||
@session_app.command("ensure-interaction-language")
|
||||
def ensure_interaction_language_cmd(
|
||||
session_id: str = typer.Argument(...),
|
||||
json_out: bool = typer.Option(False, "--json", help="Emit pristine JSON."),
|
||||
config: Path = CONFIG_OPT,
|
||||
) -> None:
|
||||
"""Pin a missing legacy language from the workspace; never override a saved value."""
|
||||
cfg = _load_config_or_exit(config)
|
||||
manifest = ensure_session_language_or_exit(cfg, session_id)
|
||||
if json_out:
|
||||
payload = manifest.model_dump(by_alias=True, mode="json")
|
||||
payload["workspace_language"] = getattr(cfg, "language", "en")
|
||||
typer.echo(json.dumps(payload, ensure_ascii=False))
|
||||
else:
|
||||
typer.echo(manifest.interaction_language)
|
||||
|
||||
|
||||
@session_app.command("reopen")
|
||||
def reopen_cmd(session_id: str = typer.Argument(...), config: Path = CONFIG_OPT) -> None:
|
||||
"""Riapre una sessione per una ripresa manuale."""
|
||||
cfg = _load_config_or_exit(config)
|
||||
load_session_or_exit(cfg, session_id)
|
||||
manifest = load_session_or_exit(cfg, session_id)
|
||||
if manifest.status == "finalized" or manifest.archived:
|
||||
typer.echo("ERROR: Session is read-only (finalized or archived)", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
ensure_session_language_or_exit(cfg, session_id)
|
||||
snapshot = load_snapshot_or_exit(cfg, session_id)
|
||||
snapshot.manifest.status = "open"
|
||||
session_repository(cfg).save_manifest(snapshot.manifest)
|
||||
|
||||
@@ -603,7 +603,7 @@ class Config(BaseModel):
|
||||
# richiedono vector_write_rest, mentre init/clear/rebuild restano solo-server.
|
||||
profile: Literal["server", "workstation"] = "server"
|
||||
# Language in which table/column descriptions and evidence are written. The skill
|
||||
# instructions stay in English; only content/output follow this language. Default
|
||||
# instructions stay in English; session dialogue uses interaction_language. Default
|
||||
# 'en' so Thoth is not bound to any customer's language.
|
||||
language: str = "en"
|
||||
paths: PathsConfig = PathsConfig()
|
||||
|
||||
@@ -106,6 +106,19 @@ class FilesystemSessionRepository:
|
||||
path = self._artifact_path(session_dir, key)
|
||||
return path.read_text() if path.exists() else None
|
||||
|
||||
def ensure_interaction_language(
|
||||
self, session_id: str, workspace_language: str
|
||||
) -> SessionSnapshot:
|
||||
from tht.session.store import pin_interaction_language
|
||||
|
||||
session_dir = self._session_dir(session_id)
|
||||
with self._lock(session_dir):
|
||||
self._require_existing(session_dir, session_id)
|
||||
manifest = SessionManifest.from_yaml(session_dir / MANIFEST)
|
||||
if pin_interaction_language(manifest, workspace_language):
|
||||
self._write_manifest(session_dir, manifest)
|
||||
return self.get(session_id)
|
||||
|
||||
def write_artifact(self, session_id: str, key: str, content: str) -> None:
|
||||
session_dir = self._session_dir(session_id)
|
||||
with self._lock(session_dir):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Session language policy, independent of workspace document content."""
|
||||
import re
|
||||
|
||||
_LANGUAGE_TAG = re.compile(
|
||||
r"(?P<language>[A-Za-z]{2,8})(?:-(?P<script>[A-Za-z]{4}))?"
|
||||
r"(?:-(?P<region>[A-Za-z]{2}|[0-9]{3}))?"
|
||||
r"(?P<rest>(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*"
|
||||
r"(?:-[0-9A-WY-Za-wy-z](?:-[A-Za-z0-9]{2,8})+)*"
|
||||
r"(?:-[xX](?:-[A-Za-z0-9]{1,8})+)?)"
|
||||
)
|
||||
|
||||
|
||||
def validate_language_tag(value: str) -> str:
|
||||
"""Validate syntax and normalize casing without depending on UI catalogs."""
|
||||
match = _LANGUAGE_TAG.fullmatch(value) if isinstance(value, str) else None
|
||||
if match is None:
|
||||
raise ValueError("interaction_language must be a BCP-47 language tag")
|
||||
parts = [match["language"].lower()]
|
||||
if match["script"]:
|
||||
parts.append(match["script"].title())
|
||||
if match["region"]:
|
||||
parts.append(match["region"].upper())
|
||||
return "-".join(parts) + match["rest"].lower()
|
||||
@@ -8,7 +8,7 @@ from typing import Literal, Self
|
||||
|
||||
import portalocker
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from tht.decisions import DecisionRecord
|
||||
|
||||
@@ -119,10 +119,18 @@ class SessionManifest(_YamlModel):
|
||||
thinking: str | None = None
|
||||
workspace_id: str | None = None
|
||||
workspace_revision: str | None = None
|
||||
interaction_language: str | None = None
|
||||
name: str | None = None
|
||||
archived: bool = False
|
||||
group: str | None = None
|
||||
|
||||
@field_validator("interaction_language")
|
||||
@classmethod
|
||||
def valid_interaction_language(cls, value: str | None) -> str | None:
|
||||
from tht.session.language import validate_language_tag
|
||||
|
||||
return validate_language_tag(value) if value is not None else None
|
||||
|
||||
|
||||
class SessionSnapshot(BaseModel):
|
||||
"""The current persisted session state, excluding the non-persistent chat stream."""
|
||||
|
||||
@@ -306,6 +306,31 @@ class PostgresSessionRepository:
|
||||
raise SessionError(f"Sessione non trovata: {manifest.id}")
|
||||
return self.get(manifest.id)
|
||||
|
||||
def ensure_interaction_language(
|
||||
self, session_id: str, workspace_language: str
|
||||
) -> SessionSnapshot:
|
||||
from tht.session.store import pin_interaction_language
|
||||
|
||||
self._require_uuid4(session_id)
|
||||
with self._transaction() as connection:
|
||||
self._lock_session(connection, session_id)
|
||||
row = connection.execute(
|
||||
text("SELECT manifest FROM thoth_sessions.sessions WHERE id = :id FOR UPDATE"),
|
||||
{"id": session_id},
|
||||
).mappings().first()
|
||||
if row is None:
|
||||
raise SessionError(f"Sessione non trovata: {session_id}")
|
||||
manifest = SessionManifest.model_validate(row["manifest"])
|
||||
if pin_interaction_language(manifest, workspace_language):
|
||||
connection.execute(
|
||||
text("UPDATE thoth_sessions.sessions "
|
||||
"SET manifest = CAST(:manifest AS jsonb), updated_at = pg_catalog.now() "
|
||||
"WHERE id = :id"),
|
||||
{"id": session_id,
|
||||
"manifest": json.dumps(manifest.model_dump(by_alias=True, mode="json"))},
|
||||
)
|
||||
return self.get(session_id)
|
||||
|
||||
def read_artifact(self, session_id: str, key: str) -> str | None:
|
||||
self._require_uuid4(session_id)
|
||||
self._require_artifact_key(key)
|
||||
|
||||
@@ -24,6 +24,10 @@ class SessionRepository(Protocol):
|
||||
|
||||
def save_manifest(self, manifest: SessionManifest) -> SessionSnapshot: ...
|
||||
|
||||
def ensure_interaction_language(
|
||||
self, session_id: str, workspace_language: str
|
||||
) -> SessionSnapshot: ...
|
||||
|
||||
def read_artifact(self, session_id: str, key: str) -> str | None: ...
|
||||
|
||||
def write_artifact(self, session_id: str, key: str, content: str) -> None: ...
|
||||
|
||||
@@ -135,7 +135,7 @@ def create_session(
|
||||
|
||||
def new_session_manifest(
|
||||
question: str, db: DatabaseConfig, *, provider=None, model=None, thinking=None,
|
||||
workspace_id=None, workspace_revision=None, name=None,
|
||||
workspace_id=None, workspace_revision=None, name=None, interaction_language=None,
|
||||
) -> SessionManifest:
|
||||
"""Create an unsaved UUIDv4 manifest for a repository-owned session."""
|
||||
now = datetime.now(UTC)
|
||||
@@ -145,9 +145,24 @@ def new_session_manifest(
|
||||
summary=_summarize(question), updated_at=now, updated_by=current_author(),
|
||||
provider=provider, model=model, thinking=thinking, workspace_id=workspace_id,
|
||||
workspace_revision=workspace_revision, name=name,
|
||||
interaction_language=interaction_language,
|
||||
)
|
||||
|
||||
|
||||
def pin_interaction_language(manifest: SessionManifest, workspace_language: str) -> bool:
|
||||
"""Apply legacy compatibility once, while the repository holds its writer lock."""
|
||||
from tht.session.language import validate_language_tag
|
||||
|
||||
if manifest.interaction_language is not None:
|
||||
return False
|
||||
if manifest.status == "finalized" or manifest.archived:
|
||||
raise SessionError("Session is read-only (finalized or archived)")
|
||||
manifest.interaction_language = validate_language_tag(workspace_language)
|
||||
manifest.updated_at = datetime.now(UTC)
|
||||
manifest.updated_by = current_author()
|
||||
return True
|
||||
|
||||
|
||||
_ASSUMPTIONS_HEADING = re.compile(
|
||||
r"^#{1,6}\s+(?:assunzioni|assumptions)\s*$", re.IGNORECASE | re.MULTILINE
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user