Claude の作業区間を n8n の work_log へ送る「n8n へ送る」タブを追加 #2

Merged
yoshikazu merged 11 commits from n8n-worklog-import into main 2026-10-12 22:14:41 +00:00
2 changed files with 162 additions and 0 deletions
Showing only changes of commit c3c42d5988 - Show all commits
+67
View File
@@ -1191,6 +1191,73 @@ def to_fields(draft, defaults):
return fields
# ---------------------------------------------------------------- n8n 連携 (work_log への取り込み)
N8N_CONFIG = APP_DIR / "n8n.json"
N8N_GAP_MIN = 30 # この分数以上の空きで作業区間を切る
N8N_MIN_MIN = 5 # この分数未満の区間は一覧に出さない
N8N_MAX_DAYS = 62 # n8n の wt-import-rows と同じ上限
class N8nError(Exception):
"""n8n 連携で利用者に見せたいエラー (UI に文言をそのまま出す)。"""
def block_source(session_id, start_epoch):
"""作業区間の識別子。同じログからは毎回同じ値になる (二重登録を防ぐ鍵)。"""
return f"chktime:{session_id}:{int(round(start_epoch * 1000))}"
def make_blocks(session, gap_min=N8N_GAP_MIN, min_min=N8N_MIN_MIN, now=None):
"""1 セッションのターンを作業区間に分ける。
前のターンの応答完了から次の発言までの空きが gap_min 分未満ならつなぎ、
gap_min 分以上なら区間を切る。min_min 分未満の区間は捨てる (進行中は残す)。
最後の区間は、応答が返っていない / ログの更新から gap_min 分経っていない
なら進行中 (送れない) とする。
"""
now = time.time() if now is None else now
gap = gap_min * 60.0
groups = []
for turn in session["turns"]:
ts = turn["ts"]
if ts is None:
continue
end = turn["endTs"] if turn["endTs"] is not None else ts
if groups and ts - groups[-1]["end"] < gap:
group = groups[-1]
group["end"] = max(group["end"], end)
group["turns"] += 1
group["last"] = turn
else:
groups.append({"start": ts, "end": end, "turns": 1, "last": turn})
out = []
for index, group in enumerate(groups):
last = group["last"]
waiting = last["firstReplyTs"] is None and not last["interrupted"]
recent = now - (session.get("mtime") or group["end"]) < gap
in_progress = index == len(groups) - 1 and (waiting or recent)
minutes = (group["end"] - group["start"]) / 60.0
if minutes < min_min and not in_progress:
continue
out.append(
{
"source": block_source(session["id"], group["start"]),
"sessionId": session["id"],
"date": local_date(group["start"]),
"start": iso(group["start"]),
"end": iso(group["end"]),
"startEpoch": group["start"],
"endEpoch": group["end"],
"minutes": int(round(minutes)),
"turns": group["turns"],
"inProgress": in_progress,
}
)
return out
# ---------------------------------------------------------------- HTTP
+95
View File
@@ -0,0 +1,95 @@
"""n8n 連携 (作業区間の計算・重なり・送信) のテスト。標準ライブラリの unittest だけを使う。
python -m unittest discover -s tests -v
"""
import json
import sys
import tempfile
import threading
import time
import unittest
from datetime import date, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import chktime # noqa: E402
MIN = 60.0
BASE = datetime(2026, 1, 5, 9, 0).timestamp() # ローカル時刻 2026-01-05 09:00
def turn(start_min, end_min, kind="prompt", reply=True, interrupted=False):
"""BASE からの分で 1 ターンを作る。reply=False なら応答なし。"""
ts = BASE + start_min * MIN
end = BASE + end_min * MIN
return {"ts": ts, "endTs": end if reply else ts, "firstReplyTs": ts + 1 if reply else None,
"kind": kind, "interrupted": interrupted}
def session(*turns, sid="s1", mtime=None):
last_end = max(t["endTs"] for t in turns) if turns else BASE
return {"id": sid, "turns": list(turns), "mtime": mtime if mtime is not None else last_end}
LATER = BASE + 24 * 3600 # 「今」: 最後のログから十分経っている
class MakeBlocksTest(unittest.TestCase):
def test_gap_29_minutes_keeps_block(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(39, 50)), now=LATER)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["minutes"], 50)
self.assertEqual(blocks[0]["turns"], 2)
def test_gap_30_minutes_splits_block(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(40, 50)), now=LATER)
self.assertEqual([b["minutes"] for b in blocks], [10, 10])
def test_parallel_prompt_keeps_block(self):
# 前の応答 (0〜20 分) の途中の 5 分に次の発言
blocks = chktime.make_blocks(session(turn(0, 20), turn(5, 30)), now=LATER)
self.assertEqual(len(blocks), 1)
self.assertEqual(blocks[0]["minutes"], 30)
def test_short_block_dropped_but_exactly_min_kept(self):
short = chktime.make_blocks(session(turn(0, 4.9)), now=LATER)
exact = chktime.make_blocks(session(turn(0, 5)), now=LATER)
self.assertEqual(short, [])
self.assertEqual(len(exact), 1)
def test_last_block_without_reply_is_in_progress(self):
blocks = chktime.make_blocks(session(turn(0, 10), turn(60, 60, reply=False)), now=LATER)
self.assertEqual([b["inProgress"] for b in blocks], [False, True])
def test_recent_log_marks_last_block_in_progress(self):
s = session(turn(0, 10))
blocks = chktime.make_blocks(s, now=s["mtime"] + 29 * MIN)
self.assertTrue(blocks[0]["inProgress"])
blocks = chktime.make_blocks(s, now=s["mtime"] + 30 * MIN)
self.assertFalse(blocks[0]["inProgress"])
def test_interrupted_last_turn_is_not_in_progress(self):
blocks = chktime.make_blocks(session(turn(0, 10, reply=False, interrupted=True), turn(1, 10)), now=LATER)
self.assertFalse(blocks[0]["inProgress"])
only = chktime.make_blocks(session(turn(0, 10), turn(11, 11, reply=False, interrupted=True)), now=LATER)
self.assertFalse(only[-1]["inProgress"])
def test_source_is_stable(self):
a = chktime.make_blocks(session(turn(0, 10)), now=LATER)
b = chktime.make_blocks(session(turn(0, 10)), now=LATER + 999)
self.assertEqual(a[0]["source"], b[0]["source"])
self.assertEqual(a[0]["source"], f"chktime:s1:{int(round(BASE * 1000))}")
def test_custom_gap_and_min(self):
blocks = chktime.make_blocks(session(turn(0, 3), turn(14, 17)), gap_min=10, min_min=3, now=LATER)
self.assertEqual([b["minutes"] for b in blocks], [3, 3])
def test_date_is_local_start_date(self):
blocks = chktime.make_blocks(session(turn(0, 10)), now=LATER)
self.assertEqual(blocks[0]["date"], "2026-01-05")
if __name__ == "__main__":
unittest.main()