career-emulator-engine 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- career_emulator_engine-1.0.1/PKG-INFO +52 -0
- career_emulator_engine-1.0.1/README.md +20 -0
- career_emulator_engine-1.0.1/career_emulator/storage.py +359 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/PKG-INFO +52 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/SOURCES.txt +9 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/dependency_links.txt +1 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/entry_points.txt +3 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/requires.txt +7 -0
- career_emulator_engine-1.0.1/career_emulator_engine.egg-info/top_level.txt +1 -0
- career_emulator_engine-1.0.1/pyproject.toml +107 -0
- career_emulator_engine-1.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: career_emulator_engine
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: TechArena English track: career simulation engine (CLI + MCP). PyPI: career-emulator-engine.
|
|
5
|
+
Author-email: Yuwei <yuwei.willow.z@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/RAI-Internal/techarena-career-sim
|
|
8
|
+
Project-URL: Documentation, https://career-emulator.readthedocs.io
|
|
9
|
+
Project-URL: Source, https://github.com/RAI-Internal/career-emulator-engine
|
|
10
|
+
Project-URL: Issues, https://github.com/RAI-Internal/techarena-career-sim/issues
|
|
11
|
+
Keywords: python,career-sim,simulation,agent,llm,techarena,competition,mcp client,client,mcp,fastmcp,mcp server,model context protocol
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Intended Audience :: Developers
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: <4,>=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: msgpack>=1.0.0
|
|
27
|
+
Requires-Dist: aiosqlite>=0.22.1
|
|
28
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
29
|
+
Requires-Dist: career-emulator-techarena==1.0.*
|
|
30
|
+
Provides-Extra: mcp
|
|
31
|
+
Requires-Dist: fastmcp>=2; extra == "mcp"
|
|
32
|
+
|
|
33
|
+
# career-emulator-engine
|
|
34
|
+
|
|
35
|
+
English **TechArena** simulation engine (CLI + MCP).
|
|
36
|
+
Separate from [`career-emulator`](https://pypi.org/project/career-emulator/) (BDCI / Chinese) and from the dataset [`career-emulator-techarena`](https://pypi.org/project/career-emulator-techarena/).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install "career-emulator-engine[mcp]"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
career-emulator-engine update --source distribution --split dev
|
|
48
|
+
career-emulator-engine play
|
|
49
|
+
career-emulator-engine-mcp --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Participant starter: [techarena-career-sim](https://github.com/RAI-Internal/techarena-career-sim)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# career-emulator-engine
|
|
2
|
+
|
|
3
|
+
English **TechArena** simulation engine (CLI + MCP).
|
|
4
|
+
Separate from [`career-emulator`](https://pypi.org/project/career-emulator/) (BDCI / Chinese) and from the dataset [`career-emulator-techarena`](https://pypi.org/project/career-emulator-techarena/).
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install "career-emulator-engine[mcp]"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick use
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
career-emulator-engine update --source distribution --split dev
|
|
16
|
+
career-emulator-engine play
|
|
17
|
+
career-emulator-engine-mcp --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Participant starter: [techarena-career-sim](https://github.com/RAI-Internal/techarena-career-sim)
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""SQLite persistence for Career Emulator sessions and logs."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal, cast
|
|
14
|
+
|
|
15
|
+
import aiosqlite
|
|
16
|
+
|
|
17
|
+
from career_emulator.pretty_print import _red
|
|
18
|
+
from career_emulator.server.models import GameSession, LogEntry
|
|
19
|
+
|
|
20
|
+
DatasetSplit = Literal["dev", "dev_final", "test", "test_final", "*"]
|
|
21
|
+
DatasetSource = Literal["distribution", "github"]
|
|
22
|
+
|
|
23
|
+
DATASET_REPO_URL = "https://github.com/Flash-Tomato/TomatoEmuData-Test"
|
|
24
|
+
DATA_DIR = Path(__file__).with_name("data")
|
|
25
|
+
DATASET_DIR = DATA_DIR / "dataset"
|
|
26
|
+
DATASET_SPLIT: DatasetSplit = cast(DatasetSplit, os.environ.get("CAREER_EMULATOR_DATASET", "dev"))
|
|
27
|
+
DEFAULT_DATASET_SOURCE: DatasetSource = cast(DatasetSource, os.getenv("CAREER_EMULATOR_DATASET_SOURCE") or "distribution")
|
|
28
|
+
DISTRIBUTION_PACKAGE_MODULE = "career_emulator_techarena"
|
|
29
|
+
DEFAULT_DB_PATH = Path(os.environ.get("CAREER_EMULATOR_DB", DATA_DIR / ".career_emulator.sqlite3"))
|
|
30
|
+
DEFAULT_LOG_DIR = Path(os.environ.get("CAREER_EMULATOR_LOG_DIR", DATA_DIR / "logs"))
|
|
31
|
+
MAX_NOTE_LENGTH = max(1, int(os.environ.get("CAREER_EMULATOR_MAX_NOTE_LENGTH", "1000")))
|
|
32
|
+
MAX_SESSIONS = max(1, int(os.environ.get("CAREER_EMULATOR_MAX_SESSIONS", "20")))
|
|
33
|
+
|
|
34
|
+
_session_loggers: dict[str, logging.Logger] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def truncate_note(note: str, max_length: int = MAX_NOTE_LENGTH) -> str:
|
|
38
|
+
"""Return a note trimmed to the configured maximum length.
|
|
39
|
+
|
|
40
|
+
:param note: Raw agent note text.
|
|
41
|
+
:param max_length: Maximum allowed note length.
|
|
42
|
+
:returns: Trimmed note text.
|
|
43
|
+
"""
|
|
44
|
+
compact = note.strip()
|
|
45
|
+
if len(compact) <= max_length:
|
|
46
|
+
return compact
|
|
47
|
+
return f"{compact[: max_length - 3]}..."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize_git_remote_url(url: str) -> str:
|
|
51
|
+
"""Return a canonical form for comparing git remote URLs."""
|
|
52
|
+
return url.strip().removesuffix(".git").rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _dataset_origin_matches(expected_url: str, dataset_dir: Path) -> bool:
|
|
56
|
+
"""Return whether ``origin`` points at the expected dataset repository."""
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["git", "-C", str(dataset_dir), "remote", "get-url", "origin"],
|
|
59
|
+
check=True,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
)
|
|
63
|
+
origin_url = result.stdout.strip()
|
|
64
|
+
return _normalize_git_remote_url(origin_url) == _normalize_git_remote_url(expected_url)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _resolve_distribution_dataset() -> Path:
|
|
68
|
+
"""Locate the dataset directory from ``career_emulator_techarena``.
|
|
69
|
+
|
|
70
|
+
:returns: Absolute path to the split-specific dataset directory inside the package.
|
|
71
|
+
:raises RuntimeError: If the package is not installed or the split directory is missing.
|
|
72
|
+
"""
|
|
73
|
+
module_name = DISTRIBUTION_PACKAGE_MODULE
|
|
74
|
+
spec = importlib.util.find_spec(module_name)
|
|
75
|
+
if spec is None or spec.origin is None:
|
|
76
|
+
msg = (
|
|
77
|
+
"Package 'career-emulator-techarena' is not installed. "
|
|
78
|
+
"Install it with: pip install career-emulator-engine"
|
|
79
|
+
)
|
|
80
|
+
raise RuntimeError(_red(msg))
|
|
81
|
+
|
|
82
|
+
package_root = Path(spec.origin).parent
|
|
83
|
+
split_dataset = package_root / DATASET_SPLIT / "dataset"
|
|
84
|
+
if not split_dataset.is_dir():
|
|
85
|
+
pkg_ver = version(module_name)
|
|
86
|
+
msg = f"Dataset split '{DATASET_SPLIT}' not found in {module_name} ({pkg_ver}) at {split_dataset}"
|
|
87
|
+
raise RuntimeError(_red(msg))
|
|
88
|
+
return split_dataset.resolve()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def update_dataset(source: Literal["github", "distribution"] = DEFAULT_DATASET_SOURCE) -> Path:
|
|
92
|
+
"""Clone, force-update, or symlink the Career Emulator event dataset.
|
|
93
|
+
|
|
94
|
+
When source is ``"github"``, the dataset is cloned/updated from the remote
|
|
95
|
+
repository. When ``"distribution"``, a symlink is created pointing to the
|
|
96
|
+
dataset directory bundled inside ``career_emulator_techarena`` for the active
|
|
97
|
+
:data:`DATASET_SPLIT`.
|
|
98
|
+
|
|
99
|
+
:param source: Where to obtain the dataset from.
|
|
100
|
+
:returns: Absolute path to the local dataset directory.
|
|
101
|
+
"""
|
|
102
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
absolute_dataset_dir = DATASET_DIR.absolute()
|
|
104
|
+
|
|
105
|
+
if source == "distribution":
|
|
106
|
+
pkg_dataset = _resolve_distribution_dataset()
|
|
107
|
+
if absolute_dataset_dir.is_symlink():
|
|
108
|
+
if absolute_dataset_dir.resolve() == pkg_dataset:
|
|
109
|
+
return absolute_dataset_dir
|
|
110
|
+
absolute_dataset_dir.unlink()
|
|
111
|
+
elif absolute_dataset_dir.is_dir():
|
|
112
|
+
shutil.rmtree(absolute_dataset_dir)
|
|
113
|
+
absolute_dataset_dir.symlink_to(pkg_dataset, target_is_directory=True)
|
|
114
|
+
return absolute_dataset_dir
|
|
115
|
+
|
|
116
|
+
if absolute_dataset_dir.is_symlink():
|
|
117
|
+
absolute_dataset_dir.unlink()
|
|
118
|
+
|
|
119
|
+
if not absolute_dataset_dir.exists():
|
|
120
|
+
subprocess.run(
|
|
121
|
+
["git", "clone", DATASET_REPO_URL, str(absolute_dataset_dir)],
|
|
122
|
+
check=True,
|
|
123
|
+
)
|
|
124
|
+
return absolute_dataset_dir
|
|
125
|
+
|
|
126
|
+
if not (absolute_dataset_dir / ".git").exists():
|
|
127
|
+
msg = f"Dataset path exists but is not a git checkout: {absolute_dataset_dir}"
|
|
128
|
+
raise RuntimeError(_red(msg))
|
|
129
|
+
|
|
130
|
+
if not _dataset_origin_matches(DATASET_REPO_URL, absolute_dataset_dir):
|
|
131
|
+
shutil.rmtree(absolute_dataset_dir)
|
|
132
|
+
return update_dataset()
|
|
133
|
+
|
|
134
|
+
subprocess.run(["git", "-C", str(absolute_dataset_dir), "fetch", "origin"], check=True)
|
|
135
|
+
subprocess.run(["git", "-C", str(absolute_dataset_dir), "reset", "--hard", "origin/main"], check=True)
|
|
136
|
+
subprocess.run(["git", "-C", str(absolute_dataset_dir), "clean", "-fd"], check=True)
|
|
137
|
+
return absolute_dataset_dir
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _close_session_logger(session_id: str) -> None:
|
|
141
|
+
"""Close and remove a cached session logger."""
|
|
142
|
+
logger = _session_loggers.pop(session_id, None)
|
|
143
|
+
if logger is None:
|
|
144
|
+
return
|
|
145
|
+
for handler in logger.handlers[:]:
|
|
146
|
+
handler.close()
|
|
147
|
+
logger.removeHandler(handler)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _session_logger(session_id: str, log_path: Path) -> logging.Logger:
|
|
151
|
+
"""Return a file logger for one session log file."""
|
|
152
|
+
cached = _session_loggers.get(session_id)
|
|
153
|
+
if cached is not None:
|
|
154
|
+
return cached
|
|
155
|
+
|
|
156
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
logger = logging.getLogger(f"career_emulator.session.{session_id}")
|
|
158
|
+
logger.setLevel(logging.INFO)
|
|
159
|
+
logger.propagate = False
|
|
160
|
+
handler = logging.FileHandler(log_path, encoding="utf-8")
|
|
161
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
162
|
+
logger.addHandler(handler)
|
|
163
|
+
_session_loggers[session_id] = logger
|
|
164
|
+
return logger
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SessionStore:
|
|
168
|
+
"""Persist game sessions and player logs in SQLite."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, db_path: Path = DEFAULT_DB_PATH, log_dir: Path = DEFAULT_LOG_DIR) -> None:
|
|
171
|
+
"""Initialize the session store.
|
|
172
|
+
|
|
173
|
+
:param db_path: SQLite database path.
|
|
174
|
+
:param log_dir: Directory for per-session log files.
|
|
175
|
+
"""
|
|
176
|
+
self.db_path = db_path
|
|
177
|
+
self.log_dir = log_dir
|
|
178
|
+
|
|
179
|
+
def log_file_path(self, session_id: str) -> Path:
|
|
180
|
+
"""Return the on-disk log file path for a session."""
|
|
181
|
+
return self.log_dir / f"{session_id}.log"
|
|
182
|
+
|
|
183
|
+
async def initialize(self) -> None:
|
|
184
|
+
"""Create database tables if they do not exist."""
|
|
185
|
+
if self.db_path.parent != Path("."):
|
|
186
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
189
|
+
await database.execute(
|
|
190
|
+
"""
|
|
191
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
192
|
+
session_id TEXT PRIMARY KEY,
|
|
193
|
+
payload TEXT NOT NULL,
|
|
194
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
195
|
+
)
|
|
196
|
+
"""
|
|
197
|
+
)
|
|
198
|
+
await database.execute(
|
|
199
|
+
"""
|
|
200
|
+
CREATE TABLE IF NOT EXISTS logs (
|
|
201
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
202
|
+
session_id TEXT NOT NULL,
|
|
203
|
+
entry_type TEXT NOT NULL,
|
|
204
|
+
message TEXT NOT NULL,
|
|
205
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
206
|
+
)
|
|
207
|
+
"""
|
|
208
|
+
)
|
|
209
|
+
await self._ensure_sessions_timestamp_column(database)
|
|
210
|
+
await database.commit()
|
|
211
|
+
|
|
212
|
+
async def _ensure_sessions_timestamp_column(self, database: aiosqlite.Connection) -> None:
|
|
213
|
+
"""Add ``updated_at`` to legacy session tables when missing."""
|
|
214
|
+
cursor = await database.execute("PRAGMA table_info(sessions)")
|
|
215
|
+
rows = await cursor.fetchall()
|
|
216
|
+
column_names = {str(row[1]) for row in rows}
|
|
217
|
+
if "updated_at" not in column_names:
|
|
218
|
+
# SQLite only allows constant defaults in ALTER TABLE ADD COLUMN.
|
|
219
|
+
await database.execute("ALTER TABLE sessions ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''")
|
|
220
|
+
await database.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE updated_at = ''")
|
|
221
|
+
|
|
222
|
+
async def save_session(self, session: GameSession) -> None:
|
|
223
|
+
"""Insert or update a session."""
|
|
224
|
+
await self.initialize()
|
|
225
|
+
payload = json.dumps(session.to_dict(), sort_keys=True)
|
|
226
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
227
|
+
await database.execute(
|
|
228
|
+
"""
|
|
229
|
+
INSERT INTO sessions (session_id, payload, updated_at)
|
|
230
|
+
VALUES (?, ?, CURRENT_TIMESTAMP)
|
|
231
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
232
|
+
payload = excluded.payload,
|
|
233
|
+
updated_at = CURRENT_TIMESTAMP
|
|
234
|
+
""",
|
|
235
|
+
(session.session_id, payload),
|
|
236
|
+
)
|
|
237
|
+
await self._enforce_max_sessions(database)
|
|
238
|
+
await database.commit()
|
|
239
|
+
|
|
240
|
+
async def _enforce_max_sessions(self, database: aiosqlite.Connection) -> None:
|
|
241
|
+
"""Delete oldest sessions when the store exceeds ``MAX_SESSIONS``."""
|
|
242
|
+
cursor = await database.execute("SELECT COUNT(*) FROM sessions")
|
|
243
|
+
row = await cursor.fetchone()
|
|
244
|
+
session_count = int(row[0]) if row is not None else 0
|
|
245
|
+
overflow = session_count - MAX_SESSIONS
|
|
246
|
+
if overflow <= 0:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
cursor = await database.execute(
|
|
250
|
+
"""
|
|
251
|
+
SELECT session_id FROM sessions
|
|
252
|
+
ORDER BY updated_at ASC, session_id ASC
|
|
253
|
+
LIMIT ?
|
|
254
|
+
""",
|
|
255
|
+
(overflow,),
|
|
256
|
+
)
|
|
257
|
+
rows = await cursor.fetchall()
|
|
258
|
+
for row in rows:
|
|
259
|
+
session_id = str(row[0])
|
|
260
|
+
await database.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
|
261
|
+
await database.execute("DELETE FROM logs WHERE session_id = ?", (session_id,))
|
|
262
|
+
self._remove_session_log_file(session_id)
|
|
263
|
+
|
|
264
|
+
async def delete_session(self, session_id: str) -> None:
|
|
265
|
+
"""Delete persisted session state and log entries for one session."""
|
|
266
|
+
await self.initialize()
|
|
267
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
268
|
+
await database.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
|
269
|
+
await database.execute("DELETE FROM logs WHERE session_id = ?", (session_id,))
|
|
270
|
+
await database.commit()
|
|
271
|
+
self._remove_session_log_file(session_id)
|
|
272
|
+
|
|
273
|
+
def delete_session_callback(self) -> Callable[[str, object], None]:
|
|
274
|
+
"""Return a callback that deletes persisted data for one session id.
|
|
275
|
+
|
|
276
|
+
The callback matches :class:`~career_emulator.lru_dict.LRUDict` eviction
|
|
277
|
+
signatures and schedules :meth:`delete_session` on the running loop.
|
|
278
|
+
"""
|
|
279
|
+
store = self
|
|
280
|
+
|
|
281
|
+
def callback(session_id: str, _value: object) -> None:
|
|
282
|
+
try:
|
|
283
|
+
loop = asyncio.get_running_loop()
|
|
284
|
+
except RuntimeError:
|
|
285
|
+
store._remove_session_log_file(session_id)
|
|
286
|
+
return
|
|
287
|
+
loop.create_task(store.delete_session(session_id))
|
|
288
|
+
|
|
289
|
+
return callback
|
|
290
|
+
|
|
291
|
+
def _remove_session_log_file(self, session_id: str) -> None:
|
|
292
|
+
"""Remove a session log file and close its logger."""
|
|
293
|
+
_close_session_logger(session_id)
|
|
294
|
+
log_path = self.log_file_path(session_id)
|
|
295
|
+
if log_path.exists():
|
|
296
|
+
log_path.unlink()
|
|
297
|
+
|
|
298
|
+
async def load_session(self, session_id: str) -> GameSession | None:
|
|
299
|
+
"""Load a session by id."""
|
|
300
|
+
await self.initialize()
|
|
301
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
302
|
+
cursor = await database.execute("SELECT payload FROM sessions WHERE session_id = ?", (session_id,))
|
|
303
|
+
row = await cursor.fetchone()
|
|
304
|
+
if row is None:
|
|
305
|
+
return None
|
|
306
|
+
payload = json.loads(str(row[0]))
|
|
307
|
+
return GameSession.from_dict(payload)
|
|
308
|
+
|
|
309
|
+
async def append_log(self, entry: LogEntry) -> None:
|
|
310
|
+
"""Append one log entry to SQLite and the session log file."""
|
|
311
|
+
await self.initialize()
|
|
312
|
+
log_path = self.log_file_path(entry.session_id)
|
|
313
|
+
logger = _session_logger(entry.session_id, log_path)
|
|
314
|
+
if not log_path.exists() or log_path.stat().st_size == 0:
|
|
315
|
+
logger.info(f"Log file: {log_path.resolve()}")
|
|
316
|
+
|
|
317
|
+
formatted = f"[{entry.entry_type}] {entry.message}"
|
|
318
|
+
logger.info(formatted)
|
|
319
|
+
|
|
320
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
321
|
+
await database.execute(
|
|
322
|
+
"INSERT INTO logs (session_id, entry_type, message) VALUES (?, ?, ?)",
|
|
323
|
+
(entry.session_id, entry.entry_type, entry.message),
|
|
324
|
+
)
|
|
325
|
+
await database.commit()
|
|
326
|
+
|
|
327
|
+
async def load_logs(self, session_id: str) -> list[LogEntry]:
|
|
328
|
+
"""Load logs for a session in insertion order."""
|
|
329
|
+
await self.initialize()
|
|
330
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
331
|
+
cursor = await database.execute(
|
|
332
|
+
"SELECT session_id, entry_type, message FROM logs WHERE session_id = ? ORDER BY id",
|
|
333
|
+
(session_id,),
|
|
334
|
+
)
|
|
335
|
+
rows = await cursor.fetchall()
|
|
336
|
+
return [LogEntry(session_id=str(row[0]), entry_type=str(row[1]), message=str(row[2])) for row in rows]
|
|
337
|
+
|
|
338
|
+
async def load_latest_logs(self, session_id: str, count: int = 10) -> list[LogEntry]:
|
|
339
|
+
"""Load the most recent log entries for a session in insertion order."""
|
|
340
|
+
await self.initialize()
|
|
341
|
+
limit = max(1, count)
|
|
342
|
+
async with aiosqlite.connect(self.db_path) as database:
|
|
343
|
+
cursor = await database.execute(
|
|
344
|
+
"""
|
|
345
|
+
SELECT session_id, entry_type, message FROM logs
|
|
346
|
+
WHERE session_id = ?
|
|
347
|
+
ORDER BY id DESC
|
|
348
|
+
LIMIT ?
|
|
349
|
+
""",
|
|
350
|
+
(session_id, limit),
|
|
351
|
+
)
|
|
352
|
+
rows = await cursor.fetchall()
|
|
353
|
+
entries = [LogEntry(session_id=str(row[0]), entry_type=str(row[1]), message=str(row[2])) for row in rows]
|
|
354
|
+
entries.reverse()
|
|
355
|
+
return entries
|
|
356
|
+
|
|
357
|
+
def log_file_header(self, session_id: str) -> str:
|
|
358
|
+
"""Return the log file path header for a session."""
|
|
359
|
+
return f"Log file: {self.log_file_path(session_id).resolve()}"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: career_emulator_engine
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: TechArena English track: career simulation engine (CLI + MCP). PyPI: career-emulator-engine.
|
|
5
|
+
Author-email: Yuwei <yuwei.willow.z@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/RAI-Internal/techarena-career-sim
|
|
8
|
+
Project-URL: Documentation, https://career-emulator.readthedocs.io
|
|
9
|
+
Project-URL: Source, https://github.com/RAI-Internal/career-emulator-engine
|
|
10
|
+
Project-URL: Issues, https://github.com/RAI-Internal/techarena-career-sim/issues
|
|
11
|
+
Keywords: python,career-sim,simulation,agent,llm,techarena,competition,mcp client,client,mcp,fastmcp,mcp server,model context protocol
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Intended Audience :: Developers
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: <4,>=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: msgpack>=1.0.0
|
|
27
|
+
Requires-Dist: aiosqlite>=0.22.1
|
|
28
|
+
Requires-Dist: aiofiles>=25.1.0
|
|
29
|
+
Requires-Dist: career-emulator-techarena==1.0.*
|
|
30
|
+
Provides-Extra: mcp
|
|
31
|
+
Requires-Dist: fastmcp>=2; extra == "mcp"
|
|
32
|
+
|
|
33
|
+
# career-emulator-engine
|
|
34
|
+
|
|
35
|
+
English **TechArena** simulation engine (CLI + MCP).
|
|
36
|
+
Separate from [`career-emulator`](https://pypi.org/project/career-emulator/) (BDCI / Chinese) and from the dataset [`career-emulator-techarena`](https://pypi.org/project/career-emulator-techarena/).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install "career-emulator-engine[mcp]"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick use
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
career-emulator-engine update --source distribution --split dev
|
|
48
|
+
career-emulator-engine play
|
|
49
|
+
career-emulator-engine-mcp --help
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Participant starter: [techarena-career-sim](https://github.com/RAI-Internal/techarena-career-sim)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
career_emulator/storage.py
|
|
4
|
+
career_emulator_engine.egg-info/PKG-INFO
|
|
5
|
+
career_emulator_engine.egg-info/SOURCES.txt
|
|
6
|
+
career_emulator_engine.egg-info/dependency_links.txt
|
|
7
|
+
career_emulator_engine.egg-info/entry_points.txt
|
|
8
|
+
career_emulator_engine.egg-info/requires.txt
|
|
9
|
+
career_emulator_engine.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
career_emulator
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "career_emulator_engine"
|
|
3
|
+
version = "1.0.1"
|
|
4
|
+
description = "TechArena English track: career simulation engine (CLI + MCP). PyPI: career-emulator-engine."
|
|
5
|
+
keywords = [
|
|
6
|
+
"python", "career-sim", "simulation", "agent", "llm", "techarena", "competition",
|
|
7
|
+
"mcp client", "client", "mcp", "fastmcp", "mcp server", "model context protocol",
|
|
8
|
+
]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11,<4"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Yuwei", email = "yuwei.willow.z@gmail.com"}]
|
|
14
|
+
classifiers = [
|
|
15
|
+
'Development Status :: 4 - Beta',
|
|
16
|
+
'Programming Language :: Python',
|
|
17
|
+
'Programming Language :: Python :: Implementation :: CPython',
|
|
18
|
+
'Programming Language :: Python :: 3',
|
|
19
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
20
|
+
'Programming Language :: Python :: 3.11',
|
|
21
|
+
'Programming Language :: Python :: 3.12',
|
|
22
|
+
'Programming Language :: Python :: 3.13',
|
|
23
|
+
'Programming Language :: Python :: 3.14',
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"msgpack>=1.0.0",
|
|
30
|
+
"aiosqlite>=0.22.1",
|
|
31
|
+
"aiofiles>=25.1.0",
|
|
32
|
+
"career-emulator-techarena==1.0.*",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
mcp = ["fastmcp>=2"]
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
test = [
|
|
40
|
+
"pytest", "pytest-asyncio", "pytest-mock", "numpy==2.*", "scipy", "scikit-learn",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
docs = [
|
|
44
|
+
"sphinx",
|
|
45
|
+
"sphinx-rtd-theme",
|
|
46
|
+
"beautifulsoup4",
|
|
47
|
+
"myst-parser>=3.0.1,<4",
|
|
48
|
+
"career-emulator-engine[mcp]",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
format = [
|
|
52
|
+
"ruff",
|
|
53
|
+
"mypy",
|
|
54
|
+
"types-aiofiles",
|
|
55
|
+
"msgpack-types",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
binary = [
|
|
59
|
+
"career-emulator-engine[mcp]",
|
|
60
|
+
"pyinstaller>=6.0,<7",
|
|
61
|
+
"python-minifier>=3.2,<4",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
dev = [
|
|
65
|
+
{ include-group = "test" },
|
|
66
|
+
{ include-group = "format" },
|
|
67
|
+
{ include-group = "docs" },
|
|
68
|
+
"setuptools",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[project.urls]
|
|
72
|
+
Homepage = "https://github.com/RAI-Internal/techarena-career-sim"
|
|
73
|
+
Documentation = "https://career-emulator.readthedocs.io"
|
|
74
|
+
Source = "https://github.com/RAI-Internal/career-emulator-engine"
|
|
75
|
+
Issues = "https://github.com/RAI-Internal/techarena-career-sim/issues"
|
|
76
|
+
|
|
77
|
+
[project.scripts]
|
|
78
|
+
career-emulator-engine = "career_emulator.cli:main"
|
|
79
|
+
career-emulator-engine-mcp = "career_emulator.run_mcp:main"
|
|
80
|
+
|
|
81
|
+
[tool.uv]
|
|
82
|
+
default-groups = ['dev']
|
|
83
|
+
|
|
84
|
+
[tool.setuptools.package-data]
|
|
85
|
+
career_emulator = ["**/py.typed", "**/.gitkeep"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.run]
|
|
88
|
+
omit = ["tests/*"]
|
|
89
|
+
|
|
90
|
+
[tool.pytest.ini_options]
|
|
91
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
92
|
+
testpaths = ["tests"]
|
|
93
|
+
addopts = ["-v"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff]
|
|
96
|
+
line-length = 120
|
|
97
|
+
target-version = "py311"
|
|
98
|
+
|
|
99
|
+
[tool.ruff.lint.pydocstyle]
|
|
100
|
+
convention = "pep257"
|
|
101
|
+
|
|
102
|
+
[tool.pylint.MAIN]
|
|
103
|
+
ignore-paths = ["^(examples|tests|docs|scripts)/.*"]
|
|
104
|
+
py-version = "3.11"
|
|
105
|
+
|
|
106
|
+
[tool.pylint.FORMAT]
|
|
107
|
+
max-line-length = 120
|