closebench 0.1.0__py3-none-any.whl

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.
closebench/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """closebench — deterministic, seeded double-entry ledgers with planted closing errors
2
+ and a programmatic grader. Test fixtures for bookkeeping agents.
3
+
4
+ from closebench.world import World
5
+ from closebench.tools import Session
6
+ from closebench.grader import grade
7
+ """
8
+ __version__ = "0.1.0"
closebench/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ """Интерфейс агента: состояние сессии на диске, истина только в памяти процесса.
2
+ Агент вызывает инструменты и не имеет доступа к посаженным ошибкам."""
3
+ from __future__ import annotations
4
+ import argparse, json, os, sys
5
+ from closebench.world import World
6
+ from closebench.tools import Session, tool_spec
7
+ from closebench.grader import grade
8
+ from closebench.strings import T, LANGS
9
+
10
+ RUNS = os.environ.get("CLOSEBENCH_RUNS", os.path.join(os.getcwd(), "closebench_runs"))
11
+
12
+ def state_path(sid): return os.path.join(RUNS, f"session_{sid}.json")
13
+
14
+ def mode_for(seed: int) -> str:
15
+ """Режим кодируется номером сида, а не флагом: флаг виден агенту в командной
16
+ строке и сам по себе является подсказкой о характере задачи."""
17
+ return "absence" if seed >= 1000 else "hard"
18
+
19
+ def load(sid):
20
+ p = state_path(sid)
21
+ if not os.path.exists(p): return None
22
+ return json.load(open(p))
23
+
24
+ def save(sid, st):
25
+ os.makedirs(RUNS, exist_ok=True)
26
+ json.dump(st, open(state_path(sid), "w"), ensure_ascii=False)
27
+
28
+ def rebuild(sid):
29
+ """Восстанавливаем мир по seed и переигрываем проводки агента."""
30
+ st = load(sid)
31
+ if not st: return None, None, None
32
+ # Сессии, записанные до появления поля lang, были русскими.
33
+ w = World(seed=st["seed"], lang=st.get("lang", "ru"),
34
+ difficulty=st.get("mode", "hard")).build()
35
+ s = Session(w)
36
+ for a in st["posted"]:
37
+ s.post_entry(a["date"], a["memo"], a["lines"])
38
+ s.external_calls = st.get("calls", 0)
39
+ return st, w, s
40
+
41
+ def tools_text(lang: str) -> str:
42
+ return "\n".join(T(lang, "cli.tool_line", **t) for t in tool_spec(lang))
43
+
44
+ def main():
45
+ ap = argparse.ArgumentParser()
46
+ ap.add_argument("--session", required=True)
47
+ ap.add_argument("--start", type=int, help="seed of a new episode")
48
+ ap.add_argument("--lang", choices=LANGS, default="en",
49
+ help="world language for --start (default: en); stored in the session")
50
+ ap.add_argument("--tool")
51
+ ap.add_argument("--args", default="{}")
52
+ ap.add_argument("--finish", action="store_true")
53
+ a = ap.parse_args()
54
+
55
+ if a.start is not None:
56
+ mode, lang = mode_for(a.start), a.lang
57
+ w = World(seed=a.start, lang=lang, difficulty=mode).build()
58
+ save(a.session, {"seed": a.start, "mode": mode, "lang": lang, "posted": [], "calls": 0})
59
+ print(T(lang, "cli.started", start=w.period_start, end=w.period_end, name=w.name))
60
+ print(T(lang, "cli.counts", entries=len(w.ledger.entries), documents=len(w.documents)))
61
+ print(T(lang, "cli.task", end=w.period_end))
62
+ print(T(lang, "cli.no_extra"))
63
+ print(T(lang, "cli.tools", tools=tools_text(lang)))
64
+ return
65
+
66
+ st, w, s = rebuild(a.session)
67
+ if not st:
68
+ print(T(a.lang, "cli.session_not_found")); sys.exit(1)
69
+ lang = w.lang
70
+
71
+ if a.finish:
72
+ res = grade(w, s)
73
+ res["seed"] = st["seed"]
74
+ st["closed"] = True
75
+ save(a.session, st)
76
+ print(json.dumps(res, ensure_ascii=False, indent=1))
77
+ return
78
+
79
+ if st.get("closed"):
80
+ print(T(lang, "cli.closed")); sys.exit(1)
81
+
82
+ try:
83
+ args = json.loads(a.args)
84
+ except json.JSONDecodeError as e:
85
+ print(T(lang, "cli.bad_json", e=e)); sys.exit(1)
86
+
87
+ fn = getattr(s, a.tool, None)
88
+ if not fn or a.tool.startswith("_"):
89
+ print(T(lang, "cli.no_tool", tool=a.tool)); sys.exit(1)
90
+ before = len(s.posted)
91
+ try:
92
+ out = fn(**args)
93
+ except TypeError as e:
94
+ print(T(lang, "cli.bad_params", e=e)); sys.exit(1)
95
+
96
+ # Успешная проводка распознаётся по факту, а не по тексту ответа (текст зависит от языка).
97
+ if a.tool == "post_entry" and len(s.posted) > before:
98
+ st["posted"].append({"date": args["date"], "memo": args["memo"], "lines": args["lines"]})
99
+ st["calls"] += 1
100
+ save(a.session, st)
101
+ print(out)
102
+
103
+ if __name__ == "__main__":
104
+ main()
closebench/grader.py ADDED
@@ -0,0 +1,79 @@
1
+ """Программная награда. Никаких рубрик и оценок человеком."""
2
+ from __future__ import annotations
3
+ from decimal import Decimal
4
+ from closebench.ledger import D
5
+
6
+ def _adj_entries(world, session):
7
+ return [e for e in world.ledger.entries if e.eid in set(session.posted)]
8
+
9
+ # Экономически эквивалентные счета: ошибка в выборе внутри группы — не ошибка по сути.
10
+ EQUIV = [
11
+ {"6900", "6300"}, # прочие расходы / профуслуги
12
+ {"2500", "2000"}, # межкомпанийные / кредиторка
13
+ {"2400", "2000"}, # доходы будущих периодов / кредиторка
14
+ {"1300", "1200"}, # расходы будущих периодов / запасы
15
+ {"6700", "6900"}, # курсовые разницы / прочие расходы
16
+ {"2100", "2000"}, # начисленные обязательства / кредиторка
17
+ {"2200", "2100"}, # зарплата к выплате / начисленные обязательства
18
+ {"6600", "6900"}, # связь и охрана / прочие расходы
19
+ ]
20
+
21
+ def _same(a: str, b: str) -> bool:
22
+ if a == b:
23
+ return True
24
+ return any(a in grp and b in grp for grp in EQUIV)
25
+
26
+ def _matches(e, want_dr, want_cr, amount, strict=True) -> bool:
27
+ amt = D(amount)
28
+ eq = (lambda a, b: a == b) if strict else _same
29
+ dr = [l for l in e.lines if l.debit == amt and eq(l.account, want_dr)]
30
+ cr = [l for l in e.lines if l.credit == amt and eq(l.account, want_cr)]
31
+ return bool(dr and cr)
32
+
33
+ def grade(world, session) -> dict:
34
+ adjs = _adj_entries(world, session)
35
+ found, partial, missed = [], [], []
36
+ matched_eids = set()
37
+ for err in world.errors:
38
+ fix = err.fix
39
+ exact = near = None
40
+ for e in adjs:
41
+ if e.eid in matched_eids: # одна проводка закрывает максимум одну ошибку
42
+ continue
43
+ if not (fix["period"][0] <= e.date <= fix["period"][1]):
44
+ continue
45
+ if fix["type"] not in ("require_entry", "require_reversal"):
46
+ continue
47
+ if _matches(e, fix["debit_account"], fix["credit_account"], fix["amount"], strict=True):
48
+ exact = e.eid
49
+ break
50
+ if near is None and _matches(e, fix["debit_account"], fix["credit_account"],
51
+ fix["amount"], strict=False):
52
+ near = e.eid
53
+ if exact:
54
+ found.append(err.err_id); matched_eids.add(exact)
55
+ elif near:
56
+ partial.append(err.err_id); matched_eids.add(near)
57
+ else:
58
+ missed.append(err.err_id)
59
+
60
+ d, c = world.ledger.tb_totals(world.period_end)
61
+ tb_balanced = d == c
62
+
63
+ # «ничего лишнего»: корректировки, не сопоставленные ни одной ошибке
64
+ spurious = [e.eid for e in adjs if e.eid not in matched_eids]
65
+
66
+ total = len(world.errors)
67
+ score = (len(found) + 0.5 * len(partial)) / total if total else 0.0
68
+ penalty = 0.1 * len(spurious)
69
+ passed = (len(found) + len(partial) == total) and tb_balanced and not spurious
70
+
71
+ return {
72
+ "passed": passed,
73
+ "score": round(max(0.0, score - penalty), 3),
74
+ "found": found, "partial": partial, "missed": missed,
75
+ "spurious_entries": spurious,
76
+ "tb_balanced": tb_balanced,
77
+ "adjustments_posted": len(adjs),
78
+ "tool_calls": getattr(session, "external_calls", len(session.calls)),
79
+ }
closebench/ledger.py ADDED
@@ -0,0 +1,117 @@
1
+ """Детерминированная двойная запись. Ядро мира: без внешних зависимостей."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ from decimal import Decimal, ROUND_HALF_UP
5
+ from typing import Iterable
6
+ from closebench.strings import T
7
+
8
+ def D(x) -> Decimal:
9
+ return Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
10
+
11
+ ASSET, LIAB, EQUITY, INCOME, EXPENSE = "asset", "liability", "equity", "income", "expense"
12
+ DEBIT_NORMAL = {ASSET, EXPENSE}
13
+
14
+ @dataclass(frozen=True)
15
+ class Account:
16
+ code: str
17
+ name: str
18
+ type: str
19
+
20
+ @dataclass(frozen=True)
21
+ class Line:
22
+ account: str
23
+ debit: Decimal = Decimal("0.00")
24
+ credit: Decimal = Decimal("0.00")
25
+
26
+ @dataclass
27
+ class Entry:
28
+ eid: str
29
+ date: str # YYYY-MM-DD
30
+ memo: str
31
+ lines: list[Line]
32
+ source: str = "manual" # manual | ap | ar | bank | accrual | payroll
33
+ ref: str = "" # номер документа
34
+ tags: dict = field(default_factory=dict)
35
+
36
+ def total_debit(self) -> Decimal:
37
+ return sum((l.debit for l in self.lines), Decimal("0.00"))
38
+
39
+ def total_credit(self) -> Decimal:
40
+ return sum((l.credit for l in self.lines), Decimal("0.00"))
41
+
42
+ def is_balanced(self) -> bool:
43
+ return self.total_debit() == self.total_credit()
44
+
45
+ class Ledger:
46
+ def __init__(self, accounts: Iterable[Account], lang: str = "en"):
47
+ self.accounts: dict[str, Account] = {a.code: a for a in accounts}
48
+ self.entries: list[Entry] = []
49
+ self.lang = lang # язык сообщений об ошибках проводки
50
+ self._seq = 0
51
+
52
+ def next_id(self, prefix="JE") -> str:
53
+ self._seq += 1
54
+ return f"{prefix}{self._seq:05d}"
55
+
56
+ def post(self, entry: Entry) -> str:
57
+ if not entry.lines:
58
+ raise ValueError(T(self.lang, "ledger.no_lines"))
59
+ for l in entry.lines:
60
+ if l.account not in self.accounts:
61
+ raise ValueError(T(self.lang, "ledger.unknown_account", account=l.account))
62
+ if l.debit < 0 or l.credit < 0:
63
+ raise ValueError(T(self.lang, "ledger.negative"))
64
+ if l.debit > 0 and l.credit > 0:
65
+ raise ValueError(T(self.lang, "ledger.both_sides"))
66
+ if not entry.is_balanced():
67
+ raise ValueError(T(self.lang, "ledger.unbalanced",
68
+ d=entry.total_debit(), c=entry.total_credit()))
69
+ self.entries.append(entry)
70
+ return entry.eid
71
+
72
+ def in_period(self, start: str, end: str) -> list[Entry]:
73
+ return [e for e in self.entries if start <= e.date <= end]
74
+
75
+ def balance(self, code: str, upto: str | None = None) -> Decimal:
76
+ acc = self.accounts[code]
77
+ bal = Decimal("0.00")
78
+ for e in self.entries:
79
+ if upto and e.date > upto:
80
+ continue
81
+ for l in e.lines:
82
+ if l.account == code:
83
+ bal += l.debit - l.credit
84
+ return bal if acc.type in DEBIT_NORMAL else -bal
85
+
86
+ def trial_balance(self, upto: str) -> list[dict]:
87
+ rows = []
88
+ for code, acc in sorted(self.accounts.items()):
89
+ raw = Decimal("0.00")
90
+ for e in self.entries:
91
+ if e.date > upto:
92
+ continue
93
+ for l in e.lines:
94
+ if l.account == code:
95
+ raw += l.debit - l.credit
96
+ if raw == 0:
97
+ continue
98
+ rows.append({"code": code, "name": acc.name, "type": acc.type,
99
+ "debit": raw if raw > 0 else Decimal("0.00"),
100
+ "credit": -raw if raw < 0 else Decimal("0.00")})
101
+ return rows
102
+
103
+ def tb_totals(self, upto: str) -> tuple[Decimal, Decimal]:
104
+ rows = self.trial_balance(upto)
105
+ return (sum((r["debit"] for r in rows), Decimal("0.00")),
106
+ sum((r["credit"] for r in rows), Decimal("0.00")))
107
+
108
+ def pnl(self, start: str, end: str) -> dict:
109
+ income = expense = Decimal("0.00")
110
+ for e in self.in_period(start, end):
111
+ for l in e.lines:
112
+ t = self.accounts[l.account].type
113
+ if t == INCOME:
114
+ income += l.credit - l.debit
115
+ elif t == EXPENSE:
116
+ expense += l.debit - l.credit
117
+ return {"income": income, "expense": expense, "net": income - expense}
closebench/pack.py ADDED
@@ -0,0 +1,31 @@
1
+ """Набор задач: 10 вариантов мира с разными seed и составом ошибок.
2
+ Пишет в <outdir>/<lang>/; для одного seed ru и en совпадают по числам."""
3
+ import json, os, sys
4
+ from closebench.world import World
5
+ from closebench.strings import T, LANGS
6
+
7
+ def build_pack(seeds=range(1, 11), outdir="tasks", lang="en"):
8
+ outdir = os.path.join(outdir, lang)
9
+ os.makedirs(outdir, exist_ok=True)
10
+ index = []
11
+ for sd in seeds:
12
+ w = World(seed=sd, lang=lang).build()
13
+ snap, truth = w.snapshot(), w.truth()
14
+ json.dump(snap, open(f"{outdir}/task_{sd:02d}_world.json", "w"), ensure_ascii=False, indent=1)
15
+ json.dump(truth, open(f"{outdir}/task_{sd:02d}_truth.json", "w"), ensure_ascii=False, indent=1)
16
+ index.append({"task": f"task_{sd:02d}", "seed": sd, "lang": lang, "period": snap["period"],
17
+ "entries": len(snap["entries"]), "documents": len(snap["documents"]),
18
+ "errors": len(truth["errors"]),
19
+ "error_kinds": [e["kind"] for e in truth["errors"]]})
20
+ json.dump(index, open(f"{outdir}/index.json", "w"), ensure_ascii=False, indent=1)
21
+ return index
22
+
23
+ if __name__ == "__main__":
24
+ import argparse
25
+ ap = argparse.ArgumentParser()
26
+ ap.add_argument("--lang", choices=LANGS, default="en")
27
+ ap.add_argument("--outdir", default="tasks")
28
+ a = ap.parse_args()
29
+ idx = build_pack(outdir=a.outdir, lang=a.lang)
30
+ print(json.dumps(idx[:2], ensure_ascii=False, indent=1))
31
+ print(T(a.lang, "pack.total", n=len(idx)))
closebench/runner.py ADDED
@@ -0,0 +1,109 @@
1
+ """Запуск агента в среде. Бэкенд модели подключаемый; ключи берутся из окружения."""
2
+ from __future__ import annotations
3
+ import json, os, re, sys
4
+ from closebench.world import World
5
+ from closebench.tools import Session, tool_spec
6
+ from closebench.grader import grade
7
+ from closebench.strings import T, LANGS
8
+
9
+ CALL_RE = re.compile(r'^CALL\s+(\w+)\s*(\{.*\})?\s*$', re.M)
10
+
11
+ def system_prompt(lang: str, start: str, end: str) -> str:
12
+ tools_txt = "\n".join(T(lang, "cli.tool_line", **t) for t in tool_spec(lang))
13
+ return T(lang, "runner.system", start=start, end=end, tools=tools_txt)
14
+
15
+ def dispatch(sess: Session, name: str, args: dict) -> str:
16
+ fn = getattr(sess, name, None)
17
+ if not fn or name.startswith("_"):
18
+ return T(sess.lang, "runner.no_tool", name=name)
19
+ try:
20
+ return fn(**args)
21
+ except TypeError as e:
22
+ return T(sess.lang, "runner.bad_params", name=name, e=e)
23
+
24
+ def run_episode(seed: int, backend, max_turns: int = 30, verbose=False, lang: str = "en") -> dict:
25
+ from closebench.cli import mode_for
26
+ w = World(seed=seed, lang=lang, difficulty=mode_for(seed)).build()
27
+ s = Session(w)
28
+ sys_prompt = system_prompt(lang, w.period_start, w.period_end)
29
+ # Слово-стоп ищется как целое слово: «DONE» иначе ловился бы внутри «undone».
30
+ done_re = re.compile(r"\b" + re.escape(T(lang, "runner.done")) + r"\b")
31
+ msgs = [{"role": "user", "content": T(lang, "runner.begin")}]
32
+ transcript = []
33
+ for turn in range(max_turns):
34
+ reply = backend(sys_prompt, msgs)
35
+ transcript.append({"role": "assistant", "text": reply})
36
+ if verbose: print(f"{T(lang, 'runner.turn', n=turn+1)}\n{reply[:900]}\n")
37
+ calls = CALL_RE.findall(reply)
38
+ if not calls:
39
+ break
40
+ results = []
41
+ for name, raw in calls:
42
+ try:
43
+ args = json.loads(raw) if raw else {}
44
+ except json.JSONDecodeError:
45
+ results.append(T(lang, "runner.bad_json", name=name)); continue
46
+ results.append(f"[{name}]\n{dispatch(s, name, args)}")
47
+ obs = "\n\n".join(results)
48
+ msgs.append({"role": "assistant", "content": reply})
49
+ msgs.append({"role": "user", "content": T(lang, "runner.results", obs=obs)})
50
+ transcript.append({"role": "tool", "text": obs})
51
+ if done_re.search(reply.upper()):
52
+ break
53
+ res = grade(w, s)
54
+ res["seed"] = seed
55
+ res["turns"] = turn + 1
56
+ return {"grade": res, "transcript": transcript, "tool_log": s.calls}
57
+
58
+ # ---------- бэкенды ----------
59
+ def anthropic_backend(model="claude-sonnet-5"):
60
+ import urllib.request
61
+ key = os.environ.get("ANTHROPIC_API_KEY")
62
+ if not key: raise RuntimeError("ANTHROPIC_API_KEY is not set")
63
+ def call(system, msgs):
64
+ body = json.dumps({"model": model, "max_tokens": 2000, "system": system,
65
+ "messages": msgs}).encode()
66
+ req = urllib.request.Request("https://api.anthropic.com/v1/messages", data=body,
67
+ headers={"x-api-key": key, "anthropic-version": "2023-06-01",
68
+ "content-type": "application/json"})
69
+ with urllib.request.urlopen(req, timeout=120) as r:
70
+ d = json.load(r)
71
+ return "".join(b.get("text", "") for b in d.get("content", []))
72
+ return call
73
+
74
+ def openai_backend(model="gpt-5"):
75
+ import urllib.request
76
+ key = os.environ.get("OPENAI_API_KEY")
77
+ if not key: raise RuntimeError("OPENAI_API_KEY is not set")
78
+ def call(system, msgs):
79
+ body = json.dumps({"model": model, "messages": [{"role": "system", "content": system}] + msgs}).encode()
80
+ req = urllib.request.Request("https://api.openai.com/v1/chat/completions", data=body,
81
+ headers={"authorization": f"Bearer {key}", "content-type": "application/json"})
82
+ with urllib.request.urlopen(req, timeout=120) as r:
83
+ d = json.load(r)
84
+ return d["choices"][0]["message"]["content"]
85
+ return call
86
+
87
+ if __name__ == "__main__":
88
+ import argparse
89
+ ap = argparse.ArgumentParser()
90
+ ap.add_argument("--seeds", default="1,2,3,4,5,6,7,8,9,10")
91
+ ap.add_argument("--backend", default="anthropic")
92
+ ap.add_argument("--model", default="claude-sonnet-5")
93
+ ap.add_argument("--lang", choices=LANGS, default="en")
94
+ ap.add_argument("--out", default="runs/result.json")
95
+ a = ap.parse_args()
96
+ be = {"anthropic": anthropic_backend, "openai": openai_backend}[a.backend](a.model)
97
+ results = []
98
+ for sd in [int(x) for x in a.seeds.split(",")]:
99
+ r = run_episode(sd, be, lang=a.lang)
100
+ results.append(r["grade"])
101
+ print(json.dumps(r["grade"], ensure_ascii=False))
102
+ passed = sum(1 for r in results if r["passed"])
103
+ summary = {"model": a.model, "lang": a.lang, "n": len(results), "pass_rate": passed / len(results),
104
+ "mean_score": round(sum(r["score"] for r in results) / len(results), 3),
105
+ "results": results}
106
+ os.makedirs(os.path.dirname(a.out), exist_ok=True)
107
+ json.dump(summary, open(a.out, "w"), ensure_ascii=False, indent=2)
108
+ print(T(a.lang, "runner.summary"),
109
+ json.dumps({k: summary[k] for k in ("model", "lang", "n", "pass_rate", "mean_score")}, ensure_ascii=False))