n8n 連携: 期間の作業区間を集め、送信済み・重なりを判定する

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 03:12:11 +09:00
co-authored by Claude Opus 5.5
parent c3c42d5988
commit 38a3307cd4
2 changed files with 151 additions and 0 deletions
+72
View File
@@ -1258,6 +1258,78 @@ def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
return out 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 # ---------------------------------------------------------------- HTTP
+79
View File
@@ -91,5 +91,84 @@ class MakeBlocksTest(unittest.TestCase):
self.assertEqual(blocks[0]["date"], "2026-01-05") 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__": if __name__ == "__main__":
unittest.main() unittest.main()