From 38a3307cd4edf5df4349ce1d602ab6f499e0e78a Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Tue, 13 Oct 2026 03:12:11 +0900 Subject: [PATCH] =?UTF-8?q?n8n=20=E9=80=A3=E6=90=BA:=20=E6=9C=9F=E9=96=93?= =?UTF-8?q?=E3=81=AE=E4=BD=9C=E6=A5=AD=E5=8C=BA=E9=96=93=E3=82=92=E9=9B=86?= =?UTF-8?q?=E3=82=81=E3=80=81=E9=80=81=E4=BF=A1=E6=B8=88=E3=81=BF=E3=83=BB?= =?UTF-8?q?=E9=87=8D=E3=81=AA=E3=82=8A=E3=82=92=E5=88=A4=E5=AE=9A=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- chktime.py | 72 ++++++++++++++++++++++++++++++++++++++++++ tests/test_n8n.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/chktime.py b/chktime.py index 2fd9e68..22ff7c6 100644 --- a/chktime.py +++ b/chktime.py @@ -1258,6 +1258,78 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): return out +def default_task_name(folder, title): + """タスク名の初期値: [作業フォルダ名] セッションのタイトル。""" + name = Path(str(folder).replace("\\", "/")).name if folder else "" + title = one_line(title, 200) + return f"[{name}] {title}" if name else title + + +def collect_blocks(store, project_id, start, end, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None): + """期間 [start, end] (date, ローカル) に始まる作業区間を集める。project_id が空なら全プロジェクト。""" + lo = datetime.combine(start, datetime.min.time()).timestamp() + hi = datetime.combine(end + timedelta(days=1), datetime.min.time()).timestamp() + projects = store.projects() + if project_id: + projects = [p for p in projects if p["id"] == project_id] + if not projects: + raise FileNotFoundError(project_id) + out = [] + for project in projects: + for path in store.session_files(project["id"]): + if path.stat().st_mtime < lo: + continue # 期間より前に書き終わったログは開かない + session = store.session(path) + if session["startEpoch"] is None or session["startEpoch"] >= hi: + continue + folder = session.get("cwd") or project["path"] + for block in make_blocks(session, gap_min, min_min, now): + if not lo <= block["startEpoch"] < hi: + continue + block.update( + { + "project": project["id"], + "projectPath": folder, + "title": session["title"], + "taskName": default_task_name(folder, session["title"]), + } + ) + out.append(block) + out.sort(key=lambda b: b["startEpoch"]) + return out + + +def mark_blocks(blocks, rows, deleted_sources, now=None): + """n8n の work_log の行と突き合わせて、送信済み / 削除済み / 重なりを付ける。 + + 重なりは [開始, 終了) が少しでも重なること (接しているだけは重ならない)。 + 相手は work_log の行 (進行中は終了 = now) と一覧の他の区間。同じ source は除く。 + """ + now = time.time() if now is None else now + sent = {r.get("source") for r in rows if r.get("source")} + deleted = set(deleted_sources or ()) + spans = [] + for row in rows: + start = parse_ts(row.get("start_time")) + if start is None: + continue + end = parse_ts(row.get("end_time")) or now + spans.append((start, end, {"kind": "row", "id": row.get("id"), "source": row.get("source") or "", + "task": row.get("task") or "", "start": iso(start), "end": iso(end)})) + for block in blocks: + spans.append((block["startEpoch"], block["endEpoch"], {"kind": "block", "source": block["source"], + "task": block.get("taskName") or "", + "start": block["start"], "end": block["end"]})) + for block in blocks: + block["sent"] = block["source"] in sent + block["deleted"] = not block["sent"] and block["source"] in deleted + block["overlaps"] = [ + info for start, end, info in spans + if info["source"] != block["source"] and start < block["endEpoch"] and block["startEpoch"] < end + ] + return blocks + + # ---------------------------------------------------------------- HTTP diff --git a/tests/test_n8n.py b/tests/test_n8n.py index 51306e6..9352d32 100644 --- a/tests/test_n8n.py +++ b/tests/test_n8n.py @@ -91,5 +91,84 @@ class MakeBlocksTest(unittest.TestCase): self.assertEqual(blocks[0]["date"], "2026-01-05") +def jsonl_line(obj): + return json.dumps(obj, ensure_ascii=False) + "\n" + + +def write_session(root, project_dir, sid, events, cwd): + """events: [(分, 'user'|'assistant'|'sidechain', 本文)] を jsonl にする。""" + folder = Path(root) / project_dir + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{sid}.jsonl" + with path.open("w", encoding="utf-8") as fh: + for minute, kind, text in events: + ts = chktime.iso(BASE + minute * MIN) + if kind == "assistant": + fh.write(jsonl_line({"type": "assistant", "timestamp": ts, "sessionId": sid, "cwd": cwd, + "message": {"model": "m", "content": [{"type": "text", "text": text}]}})) + else: + fh.write(jsonl_line({"type": "user", "timestamp": ts, "sessionId": sid, "cwd": cwd, + "isSidechain": kind == "sidechain", "message": {"role": "user", "content": text}})) + end = BASE + max(m for m, _, _ in events) * MIN + import os + os.utime(path, (end, end)) + return path + + +class CollectAndMarkTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.store = chktime.Store(self.root) + + def tearDown(self): + self.tmp.cleanup() + + def test_collect_blocks_names_and_range(self): + write_session(self.root, "C--debug-demo", "s1", + [(0, "user", "設計して"), (10, "assistant", "ok"), (60, "user", "続き"), (70, "assistant", "ok")], + r"C:\debug\demo") + blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER) + self.assertEqual(len(blocks), 2) + self.assertEqual(blocks[0]["taskName"], "[demo] 設計して") + self.assertEqual(blocks[0]["project"], "C--debug-demo") + none = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER) + self.assertEqual(none, []) + + def test_sidechain_does_not_start_turn(self): + write_session(self.root, "C--debug-demo", "s2", + [(0, "user", "調べて"), (10, "assistant", "ok"), (45, "sidechain", "sub"), (80, "user", "次"), (90, "assistant", "ok")], + r"C:\debug\demo") + blocks = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER) + # サブエージェントの行は直前のターンを 45 分まで伸ばすだけ。80 分の発言はその 35 分後なので別の区間。 + self.assertEqual([b["minutes"] for b in blocks], [45, 10]) + + def test_midnight_block_belongs_to_start_date(self): + start = datetime(2026, 1, 5, 23, 50).timestamp() + minutes = (start - BASE) / MIN + write_session(self.root, "C--debug-demo", "s3", + [(minutes, "user", "夜"), (minutes + 20, "assistant", "ok")], r"C:\debug\demo") + day1 = chktime.collect_blocks(self.store, "", date(2026, 1, 5), date(2026, 1, 5), 30, 5, now=LATER + 86400) + day2 = chktime.collect_blocks(self.store, "", date(2026, 1, 6), date(2026, 1, 6), 30, 5, now=LATER + 86400) + self.assertEqual([b["date"] for b in day1], ["2026-01-05"]) + self.assertEqual(day2, []) + + def test_mark_blocks(self): + b1 = {"source": "chktime:a:1", "startEpoch": BASE, "endEpoch": BASE + 600, "start": "", "end": "", "taskName": "A"} + b2 = {"source": "chktime:b:2", "startEpoch": BASE + 600, "endEpoch": BASE + 1200, "start": "", "end": "", "taskName": "B"} + b3 = {"source": "chktime:c:3", "startEpoch": BASE + 1199, "endEpoch": BASE + 1800, "start": "", "end": "", "taskName": "C"} + rows = [ + {"id": 1, "source": "chktime:a:1", "start_time": chktime.iso(BASE), "end_time": chktime.iso(BASE + 600), "task": "A"}, + {"id": 2, "source": "", "start_time": chktime.iso(BASE + 1700), "end_time": None, "task": "手で記録"}, + ] + chktime.mark_blocks([b1, b2, b3], rows, ["chktime:b:2"], now=BASE + 3600) + self.assertTrue(b1["sent"]) + self.assertEqual(b1["overlaps"], []) # 自分の行は除く。b2 とは接しているだけ + self.assertFalse(b2["sent"]) + self.assertTrue(b2["deleted"]) + self.assertEqual([o["source"] for o in b2["overlaps"]], ["chktime:c:3"]) # 1 秒重なる + self.assertEqual(sorted(o["kind"] for o in b3["overlaps"]), ["block", "row"]) # 進行中の行 (終了 = 今) とも重なる + + if __name__ == "__main__": unittest.main()