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:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -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):
+23
View File
@@ -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()
+9 -1
View File
@@ -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)
+4
View File
@@ -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: ...
+16 -1
View File
@@ -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
)