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")