diff --git a/chktime.py b/chktime.py index 6cb23d3..2fd9e68 100644 --- a/chktime.py +++ b/chktime.py @@ -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 diff --git a/tests/test_n8n.py b/tests/test_n8n.py new file mode 100644 index 0000000..51306e6 --- /dev/null +++ b/tests/test_n8n.py @@ -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()