fix(vault,conversations): the envelope diff drops query blocks and frontmatter, history hands the envelope apart from the user's words
This commit is contained in:
1 parent
1d1285de46
commit
65564d8541
5 files changed
+139
-10
No files matched your search
@@ -164,9 +164,9 @@ def build_entries(
|
|||||||
|
|
||||||
|
|
||||||
def messages_from_entries(
|
def messages_from_entries(
|
||||||
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
|
entries: Iterable[Mapping[str, Any]], *, stamped: bool = False
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
|
"""Messages as the model saw them; ``stamped`` adds each one's entry id and time."""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
last_message_id: str | None = None
|
last_message_id: str | None = None
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -189,7 +189,8 @@ def messages_from_entries(
|
|||||||
"role": "user",
|
"role": "user",
|
||||||
"content": _user_content(content),
|
"content": _user_content(content),
|
||||||
}
|
}
|
||||||
if with_ts:
|
if stamped:
|
||||||
|
message_out["id"] = entry.get("uuid")
|
||||||
message_out["ts"] = entry.get("timestamp")
|
message_out["ts"] = entry.get("timestamp")
|
||||||
out.append(message_out)
|
out.append(message_out)
|
||||||
last_message_id = None
|
last_message_id = None
|
||||||
@@ -205,7 +206,8 @@ def messages_from_entries(
|
|||||||
out[-1]["content"].extend(blocks)
|
out[-1]["content"].extend(blocks)
|
||||||
else:
|
else:
|
||||||
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
|
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
|
||||||
if with_ts:
|
if stamped:
|
||||||
|
message_out["id"] = entry.get("uuid")
|
||||||
message_out["ts"] = entry.get("timestamp")
|
message_out["ts"] = entry.get("timestamp")
|
||||||
out.append(message_out)
|
out.append(message_out)
|
||||||
last_message_id = message_id
|
last_message_id = message_id
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from beaver_gateway.backends.transcript import (
|
|||||||
)
|
)
|
||||||
from beaver_gateway.conversations.kinds import KINDS, Kind
|
from beaver_gateway.conversations.kinds import KINDS, Kind
|
||||||
from beaver_gateway.conversations.state import State, iso
|
from beaver_gateway.conversations.state import State, iso
|
||||||
|
from beaver_gateway.conversations.texts import split_service
|
||||||
from beaver_gateway.frontends.markdown.history import load_messages
|
from beaver_gateway.frontends.markdown.history import load_messages
|
||||||
from beaver_gateway.storage.models import (
|
from beaver_gateway.storage.models import (
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -482,9 +483,31 @@ class Rows(State):
|
|||||||
return await load_messages(
|
return await load_messages(
|
||||||
session, conversation_id=cast("int", conv.id)
|
session, conversation_id=cast("int", conv.id)
|
||||||
)
|
)
|
||||||
return messages_from_entries(
|
messages = messages_from_entries(
|
||||||
cast("Any", await self.entries(conv)), with_ts=True
|
cast("Any", await self.entries(conv)), stamped=True
|
||||||
)
|
)
|
||||||
|
return [self._unwrapped(message) for message in messages]
|
||||||
|
|
||||||
|
def _unwrapped(self, message: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if message["role"] != "user":
|
||||||
|
return message
|
||||||
|
content = message["content"]
|
||||||
|
if isinstance(content, str):
|
||||||
|
text, service = split_service(content, self._texts)
|
||||||
|
return (
|
||||||
|
message
|
||||||
|
if service is None
|
||||||
|
else {**message, "content": text, "service": service}
|
||||||
|
)
|
||||||
|
for index, block in enumerate(content):
|
||||||
|
if block.get("type") != "text":
|
||||||
|
continue
|
||||||
|
text, service = split_service(str(block.get("text") or ""), self._texts)
|
||||||
|
if service is not None:
|
||||||
|
blocks = [*content]
|
||||||
|
blocks[index] = {**block, "text": text}
|
||||||
|
return {**message, "content": blocks, "service": service}
|
||||||
|
return message
|
||||||
|
|
||||||
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
|
||||||
if conv.session_id is None:
|
if conv.session_id is None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"NewDayContext",
|
"NewDayContext",
|
||||||
"SeedContext",
|
"SeedContext",
|
||||||
"UserSaid",
|
"UserSaid",
|
||||||
|
"split_service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -127,3 +129,12 @@ class ConversationTexts:
|
|||||||
"; {fixed} open tool calls received tool_result «{interrupted}»"
|
"; {fixed} open tool calls received tool_result «{interrupted}»"
|
||||||
)
|
)
|
||||||
envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts)
|
||||||
|
|
||||||
|
|
||||||
|
def split_service(text: str, texts: ConversationTexts) -> tuple[str, str | None]:
|
||||||
|
bundle = re.escape(texts.bundle_header).replace(r"\{since\}", ".+")
|
||||||
|
heads = f"{re.escape(texts.envelope.header)}|{bundle}"
|
||||||
|
match = re.search(rf"(?:\A|\n\n)(?=(?:{heads})$)", text, re.MULTILINE)
|
||||||
|
if match is None:
|
||||||
|
return text, None
|
||||||
|
return text[: match.start()], text[match.end() :]
|
||||||
@@ -13,6 +13,7 @@ import difflib
|
|||||||
import fnmatch
|
import fnmatch
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -30,6 +31,8 @@ __all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"]
|
|||||||
|
|
||||||
_log = logging.getLogger("beaver_gateway.vault.watch")
|
_log = logging.getLogger("beaver_gateway.vault.watch")
|
||||||
|
|
||||||
|
_FENCE = re.compile(r"^\s*(`{3,}|~{3,})\s*(.*?)\s*$")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class WatchRules:
|
class WatchRules:
|
||||||
@@ -44,6 +47,8 @@ class WatchRules:
|
|||||||
names: tuple[str, ...] = ()
|
names: tuple[str, ...] = ()
|
||||||
ignore: tuple[str, ...] = ()
|
ignore: tuple[str, ...] = ()
|
||||||
suffixes: tuple[str, ...] = (".md",)
|
suffixes: tuple[str, ...] = (".md",)
|
||||||
|
skip_fences: tuple[str, ...] = ("tasks", "dataview", "dataviewjs")
|
||||||
|
"""Fenced blocks of these languages are queries: ``full`` files drop them."""
|
||||||
|
|
||||||
def kind(self, rel: str, *, today: str) -> str | None:
|
def kind(self, rel: str, *, today: str) -> str | None:
|
||||||
"""The most specific matching pattern wins, whichever list it is in."""
|
"""The most specific matching pattern wins, whichever list it is in."""
|
||||||
@@ -140,7 +145,7 @@ class VaultWatch:
|
|||||||
if kind is None:
|
if kind is None:
|
||||||
continue
|
continue
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
lines = _read_lines(path)
|
lines = self._lines(path, full=kind == "full")
|
||||||
self._seen[rel] = _Seen(
|
self._seen[rel] = _Seen(
|
||||||
lines=lines if kind == "full" else None, count=len(lines)
|
lines=lines if kind == "full" else None, count=len(lines)
|
||||||
)
|
)
|
||||||
@@ -158,7 +163,7 @@ class VaultWatch:
|
|||||||
self._pending.pop(rel, None)
|
self._pending.pop(rel, None)
|
||||||
return None
|
return None
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
lines = _read_lines(path)
|
lines = self._lines(path, full=kind == "full")
|
||||||
seen = self._seen.get(rel)
|
seen = self._seen.get(rel)
|
||||||
change = Change(
|
change = Change(
|
||||||
path=rel,
|
path=rel,
|
||||||
@@ -186,12 +191,16 @@ class VaultWatch:
|
|||||||
for change in changes:
|
for change in changes:
|
||||||
path = self.root / change.path
|
path = self.root / change.path
|
||||||
with contextlib.suppress(OSError):
|
with contextlib.suppress(OSError):
|
||||||
lines = _read_lines(path)
|
lines = self._lines(path, full=change.full)
|
||||||
self._seen[change.path] = _Seen(
|
self._seen[change.path] = _Seen(
|
||||||
lines=lines if change.full else None, count=len(lines)
|
lines=lines if change.full else None, count=len(lines)
|
||||||
)
|
)
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
|
def _lines(self, path: Path, *, full: bool) -> list[str]:
|
||||||
|
lines = _read_lines(path)
|
||||||
|
return _prose(lines, self.rules.skip_fences) if full else lines
|
||||||
|
|
||||||
def _rel(self, path: Path) -> str:
|
def _rel(self, path: Path) -> str:
|
||||||
return path.resolve().relative_to(self.root.resolve()).as_posix()
|
return path.resolve().relative_to(self.root.resolve()).as_posix()
|
||||||
|
|
||||||
@@ -216,6 +225,37 @@ def _read_lines(path: Path) -> list[str]:
|
|||||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def _prose(lines: list[str], skip_fences: tuple[str, ...]) -> list[str]:
|
||||||
|
start = 0
|
||||||
|
if lines and lines[0].strip() == "---":
|
||||||
|
closing = next(
|
||||||
|
(i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"), None
|
||||||
|
)
|
||||||
|
start = closing + 1 if closing is not None else 0
|
||||||
|
out: list[str] = []
|
||||||
|
fence: str | None = None
|
||||||
|
skipping = False
|
||||||
|
for line in lines[start:]:
|
||||||
|
match = _FENCE.match(line)
|
||||||
|
if fence is None and match:
|
||||||
|
fence = match.group(1)
|
||||||
|
language = match.group(2).split(maxsplit=1)[0] if match.group(2) else ""
|
||||||
|
skipping = language.lower() in skip_fences
|
||||||
|
elif fence is not None and match and _closes(match, fence):
|
||||||
|
fence = None
|
||||||
|
if skipping:
|
||||||
|
skipping = False
|
||||||
|
continue
|
||||||
|
if not skipping and line.strip():
|
||||||
|
out.append(line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _closes(match: re.Match[str], fence: str) -> bool:
|
||||||
|
mark = match.group(1)
|
||||||
|
return mark[0] == fence[0] and len(mark) >= len(fence) and not match.group(2)
|
||||||
|
|
||||||
|
|
||||||
def _added(old: list[str], new: list[str]) -> list[str]:
|
def _added(old: list[str], new: list[str]) -> list[str]:
|
||||||
matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
|
matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
|
||||||
added: list[str] = []
|
added: list[str] = []
|
||||||
|
|||||||
+54
-1
@@ -7,7 +7,11 @@ from test_conversations import ScriptedClient, World, world
|
|||||||
|
|
||||||
from beaver_gateway.conversations.service import UserSaid
|
from beaver_gateway.conversations.service import UserSaid
|
||||||
from beaver_gateway.conversations.envelope import Envelope, RecallContext, render
|
from beaver_gateway.conversations.envelope import Envelope, RecallContext, render
|
||||||
from beaver_gateway.conversations.texts import EnvelopeTexts
|
from beaver_gateway.conversations.texts import (
|
||||||
|
ConversationTexts,
|
||||||
|
EnvelopeTexts,
|
||||||
|
split_service,
|
||||||
|
)
|
||||||
from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules
|
from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules
|
||||||
|
|
||||||
__all__ = ["world"]
|
__all__ = ["world"]
|
||||||
@@ -77,6 +81,37 @@ def test_watch_reports_only_added_lines_with_mtime() -> None:
|
|||||||
assert only.added == ["- 14:00 снова"]
|
assert only.added == ["- 14:00 снова"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_templated_note_shows_its_text_without_queries_or_frontmatter() -> None:
|
||||||
|
root, watch = vault()
|
||||||
|
rel = f"дни/{watch.today()}.md"
|
||||||
|
diary = root / rel
|
||||||
|
watch._seen.pop(rel)
|
||||||
|
diary.write_text(
|
||||||
|
"---\ntags:\n - дни\n---\n## сводка\n- \n\n\n---\n\n# планы\nпросрочены:\n"
|
||||||
|
"```tasks\nnot done\ndue before 2026-10-13\n```\n"
|
||||||
|
"обязательно сегодня:\n~~~dataview\nlist\n~~~\n"
|
||||||
|
"```python\nprint(1)\n```\n06:04 - спать\n"
|
||||||
|
)
|
||||||
|
watch.note(diary)
|
||||||
|
(change,) = watch.take()
|
||||||
|
assert change.added == [
|
||||||
|
"## сводка",
|
||||||
|
"- ",
|
||||||
|
"---",
|
||||||
|
"# планы",
|
||||||
|
"просрочены:",
|
||||||
|
"обязательно сегодня:",
|
||||||
|
"```python",
|
||||||
|
"print(1)",
|
||||||
|
"```",
|
||||||
|
"06:04 - спать",
|
||||||
|
]
|
||||||
|
append(diary, "```tasks\ndone on 2026-10-13\n```\n07:10 - встал\n")
|
||||||
|
watch.note(diary)
|
||||||
|
(later,) = watch.take()
|
||||||
|
assert later.added == ["07:10 - встал"]
|
||||||
|
|
||||||
|
|
||||||
def test_envelope_respects_ceilings_and_names_only_window() -> None:
|
def test_envelope_respects_ceilings_and_names_only_window() -> None:
|
||||||
root, watch = vault()
|
root, watch = vault()
|
||||||
today = watch.today()
|
today = watch.today()
|
||||||
@@ -129,6 +164,24 @@ def test_render_hits_total_ceiling() -> None:
|
|||||||
assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)"
|
assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_users_words_part_from_what_the_gateway_put_under_them() -> None:
|
||||||
|
texts = ConversationTexts()
|
||||||
|
envelope = f"{HEADER}\ntime: 2026-10-13 10:00 (UTC)"
|
||||||
|
bundle = texts.bundle_header.format(since="2026-10-13T10:00:00+00:00")
|
||||||
|
injects = f"{bundle}\n- [cron] tick"
|
||||||
|
assert split_service(f"hello\n\n{envelope}", texts) == ("hello", envelope)
|
||||||
|
assert split_service(f"hello\n\n{envelope}\n\n{injects}", texts) == (
|
||||||
|
"hello",
|
||||||
|
f"{envelope}\n\n{injects}",
|
||||||
|
)
|
||||||
|
assert split_service(f"hello\n\n{injects}", texts) == ("hello", injects)
|
||||||
|
assert split_service(f"\n\n{envelope}", texts) == ("", envelope)
|
||||||
|
assert split_service("hello\n\n[a note of mine]\nmore", texts) == (
|
||||||
|
"hello\n\n[a note of mine]\nmore",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_master_turn_gets_envelope_after_text_and_before_injects(
|
async def test_master_turn_gets_envelope_after_text_and_before_injects(
|
||||||
world: World,
|
world: World,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user