From 4fd381d1210232f33d509fda3b1cfa38921c1cff Mon Sep 17 00:00:00 2001 From: hugo8xx Date: Tue, 25 Aug 2026 07:48:48 +0700 Subject: [PATCH] feat: record(occurred_at=) for importing history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with khwan-engine #31. Without it an import stamps every packet with the minute it ran: replaying two months of transcripts produced packets spanning twelve minutes, so retrieval cannot tell a decision from June from one made this morning, and a recency tiebreak has nothing to work with on exactly the data most likely to hold a superseded fact. Both clients. Absent, the field is not sent at all, so an ordinary turn carries no extra bytes and an older engine sees the body it always did. Tests cover the blocking path, the background path — where the body is built once and handed to a thread, so the timestamp has to be in that copy — and the absence case. The background one waits for the thread directly rather than through flush(), which lives in another branch, so this stands alone. 0.4.0: the record signature grows a keyword, and this is the release someone would pin to get it. --- src/khwan/__init__.py | 24 +++++++---- test_occurred_at.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 test_occurred_at.py diff --git a/src/khwan/__init__.py b/src/khwan/__init__.py index 72fbadf..88957b2 100644 --- a/src/khwan/__init__.py +++ b/src/khwan/__init__.py @@ -219,7 +219,8 @@ def prepare(self, user_input: str) -> Turn: return Turn(self._request("POST", "/prepare", {"input": user_input, **self._cfg})) - def record(self, turn: Turn, answer: str, *, background: bool = False) -> dict: + def record(self, turn: Turn, answer: str, *, background: bool = False, + occurred_at: "Optional[datetime]" = None) -> dict: """Hand your model's answer back so Khwan can persist + learn. Blocking by default, and that default is deliberate. ``prepare`` for the @@ -236,15 +237,21 @@ def record(self, turn: Turn, answer: str, *, background: bool = False) -> dict: For a strict sequence at lower latency, prefer ``record`` on a thread you join before the next ``prepare`` rather than fire-and-forget. + + ``occurred_at`` says when the turn HAPPENED, when that is not now — + importing history, replaying a transcript. Without it an import stamps + every packet with the minute it ran, and retrieval cannot tell a decision + from June from one made this morning. The server clamps it to the present. """ + body: Dict[str, Any] = {"turn_token": turn.turn_token, "answer": answer} + if occurred_at is not None: + body["occurred_at"] = occurred_at.isoformat() if not background: - return self._request("POST", "/record", - {"turn_token": turn.turn_token, "answer": answer}) + return self._request("POST", "/record", body) def _send() -> None: try: - self._request("POST", "/record", - {"turn_token": turn.turn_token, "answer": answer}) + self._request("POST", "/record", body) except Exception: # noqa: BLE001 — a failed learn must not raise into a thread pass @@ -469,7 +476,8 @@ async def prepare(self, user_input: str) -> Turn: return Turn(await self._request("POST", "/prepare", {"input": user_input, **self._cfg})) - async def record(self, turn: Turn, answer: str, *, background: bool = False) -> dict: + async def record(self, turn: Turn, answer: str, *, background: bool = False, + occurred_at: "Optional[datetime]" = None) -> dict: """Hand your model's answer back so Khwan can persist + learn. Awaited by default, and that default is deliberate: the next ``prepare`` @@ -482,7 +490,9 @@ async def record(self, turn: Turn, answer: str, *, background: bool = False) -> when the turn is the last one, or when the next prepare is far enough away. Failures are swallowed; ``aclose()`` waits for what is still in flight. """ - body = {"turn_token": turn.turn_token, "answer": answer} + body: Dict[str, Any] = {"turn_token": turn.turn_token, "answer": answer} + if occurred_at is not None: + body["occurred_at"] = occurred_at.isoformat() if not background: return await self._request("POST", "/record", body) diff --git a/test_occurred_at.py b/test_occurred_at.py new file mode 100644 index 0000000..a72be69 --- /dev/null +++ b/test_occurred_at.py @@ -0,0 +1,92 @@ +"""record(occurred_at=) — dating a packet when the turn actually happened. + +An import stamps every packet with the minute it ran. Replaying two months of +transcripts produced packets spanning twelve minutes, so retrieval could not tell +a decision from June from one made this morning — and a recency tiebreak has +nothing to work with on exactly the data most likely to hold a superseded fact. + +Run: python3 test_occurred_at.py +""" + +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "src")) + +import requests # noqa: E402 + +from khwan import Khwan, Turn # noqa: E402 + +WHEN = datetime(2026, 6, 22, 11, 50, tzinfo=timezone.utc) + + +class _Resp: + status_code, headers, content = 200, {}, b"{}" + text = "{}" + + def json(self): + return {} + + +def _capturing_client(): + seen: list = [] + + def capture(method, url, **kw): + seen.append(kw.get("json") or {}) + return _Resp() + + requests.request = capture + return Khwan(api_key="k", base_url="https://example.invalid"), seen + + +def test_absent_unless_asked_for(): + """A normal turn must not start carrying a field it never needed, and an + older engine must keep seeing the body it always did.""" + real = requests.request + try: + kw, seen = _capturing_client() + kw.record(Turn({"turn_token": "t"}), "an answer") + assert "occurred_at" not in seen[-1], seen[-1] + finally: + requests.request = real + print("✓ omitted when not given — no new field on an ordinary turn") + + +def test_sent_as_iso_when_given(): + real = requests.request + try: + kw, seen = _capturing_client() + kw.record(Turn({"turn_token": "t"}), "an answer", occurred_at=WHEN) + assert seen[-1]["occurred_at"].startswith("2026-06-22T11:50"), seen[-1] + finally: + requests.request = real + print("✓ sent as ISO 8601 when given") + + +def test_sent_on_the_background_path_too(): + """The background path builds the body once and hands it to a thread; the + timestamp has to be in that copy, not added afterwards.""" + real = requests.request + try: + kw, seen = _capturing_client() + kw.record(Turn({"turn_token": "t"}), "an answer", + background=True, occurred_at=WHEN) + # Waited for directly rather than via flush(), so this test does not + # depend on a change that lives in another branch. + deadline = time.monotonic() + 5 + while not seen and time.monotonic() < deadline: + time.sleep(0.02) + assert seen, "the background write never happened" + assert seen[-1].get("occurred_at", "").startswith("2026-06-22"), seen[-1] + finally: + requests.request = real + print("✓ sent on the background path, where the body is built once") + + +if __name__ == "__main__": + test_absent_unless_asked_for() + test_sent_as_iso_when_given() + test_sent_on_the_background_path_too() + print("\n✅ occurred_at verified")