"""
Save/export orchestration helpers for the character-description page.
This module contains the filesystem-writing logic that was previously
embedded in the ``POST /api/save`` route handler. The route layer now owns
HTTP concerns, while this module owns deterministic save-folder creation and
file generation.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from fastapi import HTTPException
from pipeworks_ipc import (
compute_ipc_id,
compute_output_hash,
compute_system_prompt_hash,
payload_hash,
)
from app.save_formatting import (
build_baseline_md,
build_output_md,
build_system_prompt_md,
save_folder_name,
)
from app.nltk_support import NltkResourceError
from app.save_package import build_manifest
from app.schema import SaveRequest, SaveResponse
from app.signal_isolation import compute_delta
[docs]
def save_run(req: SaveRequest, data_dir: Path) -> SaveResponse:
"""
Persist all session state to individual files inside a new subfolder of
``data_dir`` and return the resulting ``SaveResponse``.
The implementation intentionally mirrors the previous ``app.main.save_run``
behavior so the save format, provenance hashes, and conditional files stay
byte-for-byte compatible with the pre-refactor endpoint.
"""
now = datetime.now(timezone.utc)
input_hash = payload_hash(req.payload)
folder_name = save_folder_name(now, input_hash)
save_dir = data_dir / folder_name
sp_hash = compute_system_prompt_hash(req.system_prompt)
out_hash = compute_output_hash(req.output) if req.output is not None else None
ipc: str | None = None
if req.output is not None:
ipc = compute_ipc_id(
input_hash=input_hash,
system_prompt_hash=sp_hash,
model=req.model,
temperature=req.temperature,
max_tokens=req.max_tokens,
seed=req.payload.seed,
)
save_dir.mkdir(parents=True, exist_ok=True)
files_written: list[str] = []
try:
(save_dir / "payload.json").write_text(
json.dumps(req.payload.model_dump(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
files_written.append("payload.json")
system_prompt_md = build_system_prompt_md(req.system_prompt, folder_name)
(save_dir / "system_prompt.md").write_text(system_prompt_md, encoding="utf-8")
files_written.append("system_prompt.md")
if req.output is not None:
output_md = build_output_md(
req.output,
model=req.model,
temperature=req.temperature,
max_tokens=req.max_tokens,
seed=req.payload.seed,
timestamp=now,
input_hash=input_hash,
system_prompt_hash=sp_hash,
ipc_id=ipc,
)
(save_dir / "output.md").write_text(output_md, encoding="utf-8")
files_written.append("output.md")
if req.baseline is not None:
baseline_md = build_baseline_md(req.baseline, folder_name)
(save_dir / "baseline.md").write_text(baseline_md, encoding="utf-8")
files_written.append("baseline.md")
if req.output is not None and req.baseline is not None:
try:
removed, added = compute_delta(req.baseline, req.output)
except NltkResourceError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
delta_data = {
"removed": removed,
"added": added,
"removed_count": len(removed),
"added_count": len(added),
}
(save_dir / "delta.json").write_text(
json.dumps(delta_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
files_written.append("delta.json")
if req.transformation_map is not None and len(req.transformation_map) > 0:
tmap_data = {
"rows": [row.model_dump() for row in req.transformation_map],
"row_count": len(req.transformation_map),
}
(save_dir / "transformation_map.json").write_text(
json.dumps(tmap_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
files_written.append("transformation_map.json")
metadata: dict[str, object] = {
"folder_name": folder_name,
"timestamp": now.isoformat(),
"input_hash": input_hash,
"system_prompt_hash": sp_hash,
"output_hash": out_hash,
"ipc_id": ipc,
"model": req.model,
"temperature": req.temperature,
"max_tokens": req.max_tokens,
"seed": req.payload.seed,
"world_id": req.payload.world_id,
"policy_hash": req.payload.policy_hash,
"axis_count": len(req.payload.axes),
"payload_name": req.payload_name,
"diff_change_pct": req.diff_change_pct,
}
metadata["manifest"] = build_manifest(save_dir, files_written)
(save_dir / "metadata.json").write_text(
json.dumps(metadata, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
files_written.append("metadata.json")
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to write save files to {save_dir}: {exc}",
) from exc
return SaveResponse(
folder_name=folder_name,
files=sorted(files_written),
input_hash=input_hash,
timestamp=now.isoformat(),
system_prompt_hash=sp_hash,
output_hash=out_hash,
ipc_id=ipc,
)