oc_chat
Loading...
Searching...
No Matches
migrate_sessions.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/tools/migrate_sessions.py
4
5"""
6One-time migration script — legacy session-file relocation.
7
8Purpose
9-------
10Moves legacy files into the new ``oc_chat/sessions/`` structure so
11older installs upgrade cleanly:
12
131. Root-level ``chat_log_*.json`` → ``oc_chat/sessions/Chat_logs/``
142. Root-level ``sessions/*.json`` → ``oc_chat/sessions/`` (as
15 lightweight metadata files)
16
17Run with: ``python -m oc_chat.tools.migrate_sessions``. Safe to
18re-run — files already in the target structure are skipped.
19
20Responsibilities
21----------------
22- :func:`_ensure_dirs` — create target directories idempotently.
23- :func:`_migrate_root_chat_logs` — relocate legacy chat-log files.
24- :func:`_migrate_old_sessions_dir` — relocate the legacy
25 ``sessions/`` directory.
26- :func:`main` — CLI entry point that prints a human-readable summary.
27
28Diagnostics
29-----------
30Domain: OC_CHAT.TOOLS
31Levels:
32 L2 — lifecycle events
33 L3 — semantic details
34 L4 — deep tracing
35
36Contracts
37---------
38- Idempotent: running twice on an already-migrated tree is a no-op.
39- Never overwrites a destination file silently — duplicate names are
40 preserved by skipping with a warning.
41- All ``print()`` calls are user-facing CLI output, not diagnostic.
42
43Dependencies
44------------
45- oc_diagnostics (structured debug logging via _dbg)
46"""
47
48import json
49import shutil
50from pathlib import Path
51
52from oc_diagnostics import _dbg
53
54PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent # three levels up from tools/
55NEW_SESSIONS_DIR = PROJECT_ROOT / "oc_chat" / "sessions"
56NEW_CHAT_LOGS_DIR = NEW_SESSIONS_DIR / "Chat_logs"
57OLD_SESSIONS_DIR = PROJECT_ROOT / "sessions"
58
59
61 """Create target directories if they don't exist."""
62 NEW_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
63 NEW_CHAT_LOGS_DIR.mkdir(parents=True, exist_ok=True)
64
65
66def _migrate_root_chat_logs() -> list[Path]:
67 """Move ``chat_log_*.json`` from project root into ``Chat_logs/``."""
68 migrated = []
69 for f in sorted(PROJECT_ROOT.glob("chat_log_*.json")):
70 dest = NEW_CHAT_LOGS_DIR / f.name
71 if dest.exists():
72 print(f" [skip] {f.name} — already in Chat_logs/")
73 continue
74 shutil.move(str(f), str(dest))
75 print(f" [move] {f.name} → oc_chat/sessions/Chat_logs/{f.name}")
76 migrated.append(dest)
77 return migrated
78
79
80def _convert_old_session_to_metadata(old_path: Path) -> dict | None:
81 """Convert a legacy session JSON (full conversation) to lightweight metadata format."""
82 try:
83 with open(old_path, "r", encoding="utf-8") as f:
84 data = json.load(f)
85 except (json.JSONDecodeError, OSError) as e:
86 print(f" [warn] Could not read {old_path.name}: {e}")
87 return None
88
89 messages = data.get("messages", [])
90 msg_count = sum(1 for m in messages if m.get("role") in ("user", "assistant"))
91 tool_count = sum(1 for m in messages if m.get("role") == "tool")
92
93 # Estimate token usage from messages if available
94 total_prompt = 0
95 total_completion = 0
96 total_tokens = 0
97 for m in messages:
98 usage = m.get("usage")
99 if usage:
100 total_prompt += usage.get("prompt_tokens", 0)
101 total_completion += usage.get("completion_tokens", 0)
102 total_tokens += usage.get("total_tokens", 0)
103
104 # Determine a chat log name based on the session
105 session_name = data.get("session_name", old_path.stem)
106
107 metadata = {
108 "session_name": session_name,
109 "created": data.get("timestamp", session_name.split("_", 1)[1] if "_" in session_name else "unknown"),
110 "last_modified": data.get("timestamp", "unknown"),
111 "model": data.get("model", "unknown"),
112 "reasoning_effort": data.get("reasoning_effort", "high"),
113 "system_prompt": messages[0]["content"] if messages and messages[0].get("role") == "system" else "",
114 "stats": {
115 "message_count": msg_count,
116 "tool_call_count": tool_count,
117 "total_prompt_tokens": total_prompt,
118 "total_completion_tokens": total_completion,
119 "total_tokens": total_tokens,
120 },
121 "chat_logs": [
122 f"Chat_logs/{session_name}_log.json"
123 ],
124 }
125 return metadata
126
127
128def _migrate_old_sessions_dir() -> list[Path]:
129 """Convert old ``sessions/*.json`` → new lightweight metadata in ``oc_chat/sessions/`` and move messages as chat logs."""
130 func = "_migrate_old_sessions_dir"
131 if not OLD_SESSIONS_DIR.is_dir():
132 print(" [info] No old sessions/ directory found — nothing to migrate.")
133 return []
134
135 migrated = []
136 for f in sorted(OLD_SESSIONS_DIR.glob("*.json")):
138 if metadata is None:
139 continue
140
141 # Write the lightweight session metadata file
142 meta_path = NEW_SESSIONS_DIR / f.name
143 with open(meta_path, "w", encoding="utf-8") as mf:
144 json.dump(metadata, mf, indent=2, ensure_ascii=False)
145 print(f" [meta] {f.name} → oc_chat/sessions/{f.name} (lightweight metadata)")
146
147 # Also save the full conversation as a chat log
148 try:
149 with open(f, "r", encoding="utf-8") as of:
150 old_data = json.load(of)
151 except (json.JSONDecodeError, OSError) as e:
152 # VALIDATION_ERROR — legacy file is malformed/unreadable; keep migrating with empty body
153 _dbg("OC_CHAT.TOOLS", func, f"VALIDATION_ERROR: {f.name}: {type(e).__name__}: {e}", 3)
154 old_data = {}
155
156 log_name = f"{metadata['session_name']}_log.json"
157 log_path = NEW_CHAT_LOGS_DIR / log_name
158
159 # Add a message_stats entry to the chat log
160 log_data = {
161 "session_name": metadata["session_name"],
162 "log_index": 1,
163 "timestamp": metadata["created"],
164 "model": metadata["model"],
165 "reasoning_effort": metadata["reasoning_effort"],
166 "messages": old_data.get("messages", []),
167 "usage": {
168 "prompt_tokens": metadata["stats"]["total_prompt_tokens"],
169 "completion_tokens": metadata["stats"]["total_completion_tokens"],
170 "total_tokens": metadata["stats"]["total_tokens"],
171 },
172 }
173
174 with open(log_path, "w", encoding="utf-8") as lf:
175 json.dump(log_data, lf, indent=2, ensure_ascii=False)
176 print(f" [log] {f.name} → oc_chat/sessions/Chat_logs/{log_name}")
177
178 migrated.append(meta_path)
179
180 return migrated
181
182
183def main() -> None:
184 """Run the one-time session migration."""
185 func = "main"
186 _dbg("OC_CHAT.TOOLS", func, f"source={OLD_SESSIONS_DIR} target={NEW_SESSIONS_DIR}", 2)
187 print("=" * 60)
188 print("Session Migration Tool")
189 print(f" Source sessions/: {OLD_SESSIONS_DIR}")
190 print(f" Source root chat_logs: {PROJECT_ROOT}")
191 print(f" Target: {NEW_SESSIONS_DIR}")
192 print("=" * 60)
193
195
196 print("\n--- Migrating root chat_log_*.json files ---")
197 chat_logs = _migrate_root_chat_logs()
198 print(f" Migrated {len(chat_logs)} chat log(s).")
199
200 print("\n--- Converting old sessions/ to new format ---")
201 sessions = _migrate_old_sessions_dir()
202 print(f" Migrated {len(sessions)} session(s).")
203
204 # Cleanup: remove old sessions/ directory if empty
205 if OLD_SESSIONS_DIR.is_dir() and not list(OLD_SESSIONS_DIR.iterdir()):
206 OLD_SESSIONS_DIR.rmdir()
207 print(f"\n [cleanup] Removed empty old sessions/ directory.")
208
209 print("\n✅ Migration complete!")
210 print(f" Sessions: {NEW_SESSIONS_DIR}")
211 print(f" Chat logs: {NEW_CHAT_LOGS_DIR}")
212
213
214if __name__ == "__main__":
215 main()
dict|None _convert_old_session_to_metadata(Path old_path)