Simplify chat UI and wire memory context

This commit is contained in:
mirivlad 2026-05-20 23:35:38 +08:00
parent 3d815d3ae3
commit 061cc9225a
8 changed files with 437 additions and 73 deletions

View File

@ -50,6 +50,7 @@ class PasswordRequest(BaseModel):
class MemoryRequest(BaseModel): class MemoryRequest(BaseModel):
text: str text: str
workspace: str | None = None workspace: str | None = None
scope: str | None = None
conversation_id: str | None = None conversation_id: str | None = None
memory_type: str = "note" memory_type: str = "note"
importance: float = 0.5 importance: float = 0.5
@ -145,11 +146,15 @@ def create_app() -> FastAPI:
body.workspace or settings.workspace, body.workspace or settings.workspace,
) )
history = await conversation_history(conversation.conversation_id) history = await conversation_history(conversation.conversation_id)
memory_records = await relevant_memory(
body.message, conversation.workspace, conversation.conversation_id
)
result = await runtime.run_chat( result = await runtime.run_chat(
body.message, body.message,
conversation.workspace, conversation.workspace,
body.debug, body.debug,
history_messages=history, history_messages=history,
memory_records=memory_records,
) )
await conversations.add_message( await conversations.add_message(
conversation.conversation_id, conversation.conversation_id,
@ -180,6 +185,21 @@ def create_app() -> FastAPI:
title = " ".join(message.strip().split()) title = " ".join(message.strip().split())
return title[:60] or "New chat" return title[:60] or "New chat"
async def relevant_memory(
query: str, workspace: str, conversation_id: str | None
) -> list[dict[str, str]]:
records = await memory_store.relevant(
workspace=workspace,
conversation_id=conversation_id,
query=query,
limit=8,
)
return [
{"scope": record.scope, "text": record.text}
for record in records
if record.text
]
@app.get("/v1/conversations") @app.get("/v1/conversations")
async def list_conversations() -> list[dict[str, Any]]: async def list_conversations() -> list[dict[str, Any]]:
return [conversation.model_dump() for conversation in await conversations.list()] return [conversation.model_dump() for conversation in await conversations.list()]
@ -226,6 +246,9 @@ def create_app() -> FastAPI:
body.workspace or settings.workspace, body.workspace or settings.workspace,
) )
history = await conversation_history(conversation.conversation_id) history = await conversation_history(conversation.conversation_id)
memory_records = await relevant_memory(
body.message, conversation.workspace, conversation.conversation_id
)
task = await task_store.create_task( task = await task_store.create_task(
body.message, conversation.workspace, body.debug body.message, conversation.workspace, body.debug
) )
@ -244,7 +267,9 @@ def create_app() -> FastAPI:
reasoning_parts: list[str] = [] reasoning_parts: list[str] = []
content_parts: list[str] = [] content_parts: list[str] = []
try: try:
messages = runtime.context_builder.build_basic_messages(task, history) messages = runtime.context_builder.build_basic_messages(
task, history, memory_records
)
tool_observations = await runtime._run_action_loop( tool_observations = await runtime._run_action_loop(
task.task_id, messages, conversation.workspace task.task_id, messages, conversation.workspace
) )
@ -819,6 +844,10 @@ def create_app() -> FastAPI:
record = await memory_store.add( record = await memory_store.add(
text=body.text, text=body.text,
workspace=body.workspace or settings.workspace, workspace=body.workspace or settings.workspace,
scope=body.scope
or memory_store.infer_scope(
body.text, body.workspace or settings.workspace, body.conversation_id
),
conversation_id=body.conversation_id, conversation_id=body.conversation_id,
memory_type=body.memory_type, memory_type=body.memory_type,
importance=body.importance, importance=body.importance,

View File

@ -6,8 +6,24 @@ class ContextBuilder:
self, self,
task: TaskState, task: TaskState,
history_messages: list[dict[str, str]] | None = None, history_messages: list[dict[str, str]] | None = None,
memory_records: list[dict[str, str]] | None = None,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
memory_messages = []
if memory_records:
lines = [
f"- {record.get('scope', 'memory')}: {record.get('text', '')}"
for record in memory_records
if record.get("text")
]
if lines:
memory_messages.append(
{
"role": "system",
"content": "Relevant memory:\n" + "\n".join(lines),
}
)
return [ return [
*memory_messages,
*(history_messages or []), *(history_messages or []),
{ {
"role": "user", "role": "user",

View File

@ -15,6 +15,7 @@ class MemoryRecord(BaseModel):
id: int | None = None id: int | None = None
memory_id: str memory_id: str
text: str text: str
scope: str = "workspace"
workspace: str workspace: str
conversation_id: str | None = None conversation_id: str | None = None
memory_type: str = "note" memory_type: str = "note"
@ -37,6 +38,7 @@ class MemoryStore:
id integer primary key autoincrement, id integer primary key autoincrement,
memory_id text not null unique, memory_id text not null unique,
text text not null, text text not null,
scope text not null default 'workspace',
workspace text not null, workspace text not null,
conversation_id text, conversation_id text,
memory_type text not null, memory_type text not null,
@ -53,12 +55,22 @@ class MemoryStore:
on memories(workspace, created_at) on memories(workspace, created_at)
""" """
) )
await self._ensure_scope_column(db)
await db.commit() await db.commit()
async def _ensure_scope_column(self, db: aiosqlite.Connection) -> None:
cursor = await db.execute("pragma table_info(memories)")
columns = {row[1] for row in await cursor.fetchall()}
if "scope" not in columns:
await db.execute(
"alter table memories add column scope text not null default 'workspace'"
)
async def add( async def add(
self, self,
text: str, text: str,
workspace: str, workspace: str,
scope: str = "workspace",
conversation_id: str | None = None, conversation_id: str | None = None,
memory_type: str = "note", memory_type: str = "note",
importance: float = 0.5, importance: float = 0.5,
@ -72,13 +84,14 @@ class MemoryStore:
cursor = await db.execute( cursor = await db.execute(
""" """
insert into memories( insert into memories(
memory_id, text, workspace, conversation_id, memory_type, memory_id, text, scope, workspace, conversation_id, memory_type,
importance, metadata_json, created_at, updated_at importance, metadata_json, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?) ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
memory_id, memory_id,
clean_text, clean_text,
self._normalize_scope(scope),
workspace, workspace,
conversation_id, conversation_id,
memory_type or "note", memory_type or "note",
@ -94,6 +107,7 @@ class MemoryStore:
id=row_id, id=row_id,
memory_id=memory_id, memory_id=memory_id,
text=clean_text, text=clean_text,
scope=self._normalize_scope(scope),
workspace=workspace, workspace=workspace,
conversation_id=conversation_id, conversation_id=conversation_id,
memory_type=memory_type or "note", memory_type=memory_type or "note",
@ -114,7 +128,7 @@ class MemoryStore:
cursor = await db.execute( cursor = await db.execute(
""" """
select * from memories select * from memories
where workspace = ? where workspace = ? or scope = 'global'
order by importance desc, created_at desc order by importance desc, created_at desc
limit ? limit ?
""", """,
@ -164,11 +178,75 @@ class MemoryStore:
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [self._row_to_record(row) for row in rows] return [self._row_to_record(row) for row in rows]
async def relevant(
self,
workspace: str,
conversation_id: str | None = None,
query: str = "",
limit: int = 8,
) -> list[MemoryRecord]:
await self.init()
bounded_limit = min(max(limit, 1), 30)
terms = [term.lower() for term in query.split() if len(term) >= 3]
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"""
select * from memories
where scope = 'global'
or (scope = 'workspace' and workspace = ?)
or (scope = 'conversation' and workspace = ? and conversation_id = ?)
order by
case scope
when 'global' then 0
when 'conversation' then 1
else 2
end,
importance desc,
created_at desc
limit ?
""",
(workspace, workspace, conversation_id, bounded_limit * 3),
)
rows = await cursor.fetchall()
records = [self._row_to_record(row) for row in rows]
if not terms:
return records[:bounded_limit]
matching = [
record
for record in records
if any(term in record.text.lower() for term in terms)
or record.scope == "global"
]
return matching[:bounded_limit]
def infer_scope(self, text: str, workspace: str, conversation_id: str | None) -> str:
lowered = text.lower()
global_markers = (
"user prefers",
"пользователь предпочитает",
"отвечай",
"always",
"всегда",
"rx580",
"radeon",
"vulkan",
)
if any(marker in lowered for marker in global_markers):
return "global"
if conversation_id and any(marker in lowered for marker in ("this chat", "этот чат", "диалог")):
return "conversation"
return "workspace" if workspace else "global"
def _normalize_scope(self, scope: str) -> str:
return scope if scope in {"global", "workspace", "conversation"} else "workspace"
def _row_to_record(self, row: aiosqlite.Row) -> MemoryRecord: def _row_to_record(self, row: aiosqlite.Row) -> MemoryRecord:
return MemoryRecord( return MemoryRecord(
id=row["id"], id=row["id"],
memory_id=row["memory_id"], memory_id=row["memory_id"],
text=row["text"], text=row["text"],
scope=row["scope"],
workspace=row["workspace"], workspace=row["workspace"],
conversation_id=row["conversation_id"], conversation_id=row["conversation_id"],
memory_type=row["memory_type"], memory_type=row["memory_type"],

View File

@ -42,6 +42,7 @@ class RuntimeLoop:
workspace: str | None = None, workspace: str | None = None,
debug: bool = False, debug: bool = False,
history_messages: list[dict[str, str]] | None = None, history_messages: list[dict[str, str]] | None = None,
memory_records: list[dict[str, str]] | None = None,
) -> ChatResult: ) -> ChatResult:
task = await self.task_store.create_task(message, workspace, debug) task = await self.task_store.create_task(message, workspace, debug)
await self.event_store.append( await self.event_store.append(
@ -50,7 +51,9 @@ class RuntimeLoop:
{"message": message, "workspace": workspace, "debug": debug}, {"message": message, "workspace": workspace, "debug": debug},
) )
try: try:
messages = self.context_builder.build_basic_messages(task, history_messages) messages = self.context_builder.build_basic_messages(
task, history_messages, memory_records
)
tool_observations = await self._run_action_loop(task.task_id, messages, workspace) tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
if any(observation.get("requires_approval") for observation in tool_observations): if any(observation.get("requires_approval") for observation in tool_observations):
await self.task_store.waiting_for_approval(task.task_id) await self.task_store.waiting_for_approval(task.task_id)

View File

@ -23,6 +23,31 @@ function setStatus(id, text, tone = "neutral") {
node.dataset.tone = tone; node.dataset.tone = tone;
} }
function openActivity(tab = "") {
const drawer = document.querySelector("#activity-drawer");
const backdrop = document.querySelector("#activity-backdrop");
if (!drawer || !backdrop) return;
drawer.hidden = false;
backdrop.hidden = false;
if (tab) selectActivityTab(tab);
}
function closeActivity() {
const drawer = document.querySelector("#activity-drawer");
const backdrop = document.querySelector("#activity-backdrop");
if (drawer) drawer.hidden = true;
if (backdrop) backdrop.hidden = true;
}
function selectActivityTab(tab) {
document.querySelectorAll("[data-activity-tab]").forEach((button) => {
button.classList.toggle("active", button.dataset.activityTab === tab);
});
document.querySelectorAll("[data-activity-panel]").forEach((panel) => {
panel.classList.toggle("active", panel.dataset.activityPanel === tab);
});
}
function addMessage(role, content, meta = "", options = {}) { function addMessage(role, content, meta = "", options = {}) {
const list = document.querySelector("#messages"); const list = document.querySelector("#messages");
if (!list) return; if (!list) return;
@ -756,6 +781,15 @@ function bindChat() {
document.querySelector("#reload-chat")?.addEventListener("click", () => { document.querySelector("#reload-chat")?.addEventListener("click", () => {
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error); if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
}); });
document.querySelector("#activity-open")?.addEventListener("click", () => {
openActivity("events");
});
document.querySelector("#activity-close")?.addEventListener("click", closeActivity);
document.querySelector("#activity-backdrop")?.addEventListener("click", closeActivity);
document.querySelector("#activity-drawer")?.addEventListener("click", (event) => {
const tab = event.target.closest("[data-activity-tab]");
if (tab) selectActivityTab(tab.dataset.activityTab);
});
document.querySelector("#refresh-audit")?.addEventListener("click", () => { document.querySelector("#refresh-audit")?.addEventListener("click", () => {
refreshCommandAudit().catch(console.error); refreshCommandAudit().catch(console.error);
}); });
@ -803,9 +837,6 @@ function bindChat() {
event.preventDefault(); event.preventDefault();
submitToolPassword(form).catch(console.error); submitToolPassword(form).catch(console.error);
}); });
document.querySelector("#debug")?.addEventListener("change", (event) => {
document.querySelector("#debug-panel").hidden = !event.target.checked;
});
} }
async function loadSimplePages() { async function loadSimplePages() {

View File

@ -213,9 +213,22 @@ button {
font-size: 12px; font-size: 12px;
} }
.utility-menu {
border-bottom: 1px solid rgba(255,255,255,0.12);
padding-bottom: 12px;
}
.utility-menu summary {
cursor: pointer;
color: #cbd5e1;
font-size: 13px;
font-weight: 750;
}
.side-nav { .side-nav {
display: grid; display: grid;
gap: 6px; gap: 6px;
margin-top: 10px;
} }
.side-nav a { .side-nav a {
@ -381,7 +394,7 @@ dd {
.chat-shell { .chat-shell {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto; grid-template-rows: auto minmax(0, 1fr) auto;
gap: 16px; gap: 16px;
min-width: 0; min-width: 0;
height: 100vh; height: 100vh;
@ -418,6 +431,12 @@ dd {
font-weight: 750; font-weight: 750;
} }
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.secondary-button { .secondary-button {
background: #edf2f7; background: #edf2f7;
color: #1f2937; color: #1f2937;
@ -681,26 +700,6 @@ dd {
font-size: 13px; font-size: 13px;
} }
.debug-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.8fr) minmax(280px, 0.85fr);
gap: 16px;
min-height: 180px;
}
.debug-column {
min-width: 0;
padding: 14px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
.debug-column h3 {
margin: 0 0 10px;
font-size: 13px;
}
.debug-heading { .debug-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@ -709,6 +708,97 @@ dd {
margin-bottom: 10px; margin-bottom: 10px;
} }
.activity-backdrop {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(15, 23, 42, 0.28);
}
.activity-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 21;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(520px, 100vw);
height: 100vh;
background: var(--panel);
border-left: 1px solid var(--border);
box-shadow: -18px 0 45px rgba(15, 23, 42, 0.18);
}
.drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 18px;
border-bottom: 1px solid var(--border);
}
.drawer-header h2,
.drawer-header p {
margin: 0;
}
.drawer-header h2 {
font-size: 18px;
}
.drawer-header p {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
.icon-button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 7px;
background: #edf2f7;
color: #1f2937;
font-weight: 800;
}
.drawer-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.drawer-tab {
border: 0;
border-radius: 7px;
padding: 8px 10px;
background: #edf2f7;
color: #475569;
font-size: 12px;
font-weight: 800;
}
.drawer-tab.active {
background: var(--accent);
color: #ffffff;
}
.drawer-section {
display: none;
min-height: 0;
overflow: auto;
padding: 14px;
}
.drawer-section.active {
display: block;
}
.debug-heading h3 { .debug-heading h3 {
margin: 0; margin: 0;
} }
@ -756,7 +846,7 @@ pre,
.memory-list { .memory-list {
display: grid; display: grid;
gap: 8px; gap: 8px;
max-height: 170px; max-height: none;
overflow: auto; overflow: auto;
} }
@ -909,9 +999,4 @@ pre,
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.debug-panel {
display: grid;
grid-template-columns: 1fr;
}
} }

View File

@ -17,13 +17,16 @@
</div> </div>
</div> </div>
<nav class="side-nav" aria-label="DuckLM sections"> <details class="utility-menu">
<a href="/" class="active">Chat</a> <summary>System</summary>
<a href="/approvals">Approvals</a> <nav class="side-nav" aria-label="DuckLM sections">
<a href="/skills">Skills</a> <a href="/" class="active">Chat</a>
<a href="/memory">Memory</a> <a href="/approvals">Approvals</a>
<a href="/experience">Experience</a> <a href="/skills">Skills</a>
</nav> <a href="/memory">Memory</a>
<a href="/experience">Experience</a>
</nav>
</details>
<section class="conversation-panel" aria-labelledby="conversations-title"> <section class="conversation-panel" aria-labelledby="conversations-title">
<div class="panel-heading"> <div class="panel-heading">
@ -41,7 +44,7 @@
</label> </label>
<label class="toggle-row"> <label class="toggle-row">
<input id="debug" type="checkbox" checked> <input id="debug" type="checkbox" checked>
<span>Show reasoning and events</span> <span>Debug mode</span>
</label> </label>
</section> </section>
@ -70,7 +73,10 @@
<h2 id="chat-title">Chat</h2> <h2 id="chat-title">Chat</h2>
<p id="chat-subtitle">Messages are processed by the local Qwen role mapping through Duck Core.</p> <p id="chat-subtitle">Messages are processed by the local Qwen role mapping through Duck Core.</p>
</div> </div>
<button id="reload-chat" class="secondary-button" type="button">Reload</button> <div class="header-actions">
<button id="activity-open" class="secondary-button" type="button">Activity</button>
<button id="reload-chat" class="secondary-button" type="button">Reload</button>
</div>
</header> </header>
<section id="messages" class="messages" aria-live="polite"> <section id="messages" class="messages" aria-live="polite">
@ -86,35 +92,6 @@
</article> </article>
</section> </section>
<section id="debug-panel" class="debug-panel">
<div class="debug-column">
<h3>Event Timeline</h3>
<ol id="events"></ol>
</div>
<div class="debug-column audit-column">
<div class="debug-heading">
<h3>Command Audit</h3>
<button id="refresh-audit" class="mini-button" type="button">Refresh</button>
</div>
<div id="command-audit" class="audit-list"></div>
</div>
<div class="debug-column memory-column">
<div class="debug-heading">
<h3>Workspace Memory</h3>
<button id="refresh-memory" class="mini-button" type="button">Refresh</button>
</div>
<form id="memory-form" class="memory-form">
<input id="memory-text" placeholder="Add memory for this workspace" autocomplete="off">
<button type="submit">Add</button>
</form>
<div class="memory-search-row">
<input id="memory-search-inline" placeholder="Search memory" autocomplete="off">
<button id="memory-search-button" class="mini-button" type="button">Search</button>
</div>
<div id="memory-list" class="memory-list"></div>
</div>
</section>
<form id="composer" class="composer"> <form id="composer" class="composer">
<textarea id="message" rows="3" placeholder="Напиши сообщение DuckLM...">Скажи коротко, что ты DuckLM</textarea> <textarea id="message" rows="3" placeholder="Напиши сообщение DuckLM...">Скажи коротко, что ты DuckLM</textarea>
<div class="composer-actions"> <div class="composer-actions">
@ -123,6 +100,47 @@
</div> </div>
</form> </form>
</main> </main>
<div id="activity-backdrop" class="activity-backdrop" hidden></div>
<aside id="activity-drawer" class="activity-drawer" aria-label="Activity" hidden>
<header class="drawer-header">
<div>
<h2>Activity</h2>
<p>Task details, command audit, and memory.</p>
</div>
<button id="activity-close" class="icon-button" type="button" aria-label="Close activity">x</button>
</header>
<div class="drawer-tabs" role="tablist" aria-label="Activity sections">
<button class="drawer-tab active" type="button" data-activity-tab="events">Events</button>
<button class="drawer-tab" type="button" data-activity-tab="commands">Commands</button>
<button class="drawer-tab" type="button" data-activity-tab="memory">Memory</button>
</div>
<section class="drawer-section active" data-activity-panel="events">
<ol id="events"></ol>
</section>
<section class="drawer-section" data-activity-panel="commands">
<div class="debug-heading">
<h3>Command Audit</h3>
<button id="refresh-audit" class="mini-button" type="button">Refresh</button>
</div>
<div id="command-audit" class="audit-list"></div>
</section>
<section class="drawer-section" data-activity-panel="memory">
<div class="debug-heading">
<h3>Memory</h3>
<button id="refresh-memory" class="mini-button" type="button">Refresh</button>
</div>
<form id="memory-form" class="memory-form">
<input id="memory-text" placeholder="Add memory" autocomplete="off">
<button type="submit">Add</button>
</form>
<div class="memory-search-row">
<input id="memory-search-inline" placeholder="Search memory" autocomplete="off">
<button id="memory-search-button" class="mini-button" type="button">Search</button>
</div>
<div id="memory-list" class="memory-list"></div>
</section>
</aside>
</div> </div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>

View File

@ -1,7 +1,9 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from duck_core.api import create_app from duck_core.api import create_app
from duck_core.context_builder import ContextBuilder
from duck_core.memory.store import MemoryStore from duck_core.memory.store import MemoryStore
from duck_core.tasks.state import TaskState
def test_memory_api_stores_workspace_scoped_notes(tmp_path, monkeypatch): def test_memory_api_stores_workspace_scoped_notes(tmp_path, monkeypatch):
@ -59,3 +61,105 @@ async def test_memory_store_searches_text_and_metadata(tmp_path):
assert len(results) == 1 assert len(results) == 1
assert results[0].workspace == "/tmp/duck" assert results[0].workspace == "/tmp/duck"
assert results[0].metadata["topic"] == "gpu" assert results[0].metadata["topic"] == "gpu"
async def test_memory_store_returns_relevant_global_workspace_and_chat_memory(tmp_path):
store = MemoryStore(str(tmp_path / "duck.sqlite3"))
await store.init()
await store.add("Global preference", scope="global", workspace="")
await store.add("Workspace fact", scope="workspace", workspace="/tmp/duck")
await store.add(
"Chat fact",
scope="conversation",
workspace="/tmp/duck",
conversation_id="chat_1",
)
await store.add("Other workspace fact", scope="workspace", workspace="/tmp/other")
results = await store.relevant(
workspace="/tmp/duck", conversation_id="chat_1", query="fact"
)
assert [record.text for record in results] == [
"Global preference",
"Chat fact",
"Workspace fact",
]
def test_context_builder_injects_memory_context_before_user_message():
task = TaskState(
task_id="task_1",
status="running",
user_message="Что помнить?",
workspace="/tmp/duck",
debug=True,
created_at="now",
updated_at="now",
)
messages = ContextBuilder().build_basic_messages(
task,
memory_records=[
{"scope": "global", "text": "Use Russian."},
{"scope": "workspace", "text": "DuckLM uses Vulkan."},
],
)
assert messages[0]["role"] == "system"
assert "Relevant memory" in messages[0]["content"]
assert "global: Use Russian." in messages[0]["content"]
assert messages[-1]["content"] == "Что помнить?"
def test_chat_api_injects_relevant_memory_into_model_context(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
seen_messages = []
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
if role == "action":
return ModelResponse(
role=role,
model="local-main",
content='{"kind":"action_directive","intent":"answer","risk_level":"none","actions":[]}',
reasoning_content=None,
raw={},
latency_ms=1.0,
)
seen_messages.append(messages)
return ModelResponse(
role=role,
model="local-main",
content="remembered",
reasoning_content=None,
raw={},
latency_ms=1.0,
)
from duck_core.model_client import ModelResponse
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
client = TestClient(create_app())
conversation = client.post(
"/v1/conversations",
json={"title": "Memory chat", "workspace": str(tmp_path)},
).json()
client.post(
"/v1/memory",
json={
"text": "User prefers direct Russian answers.",
"workspace": str(tmp_path),
"conversation_id": conversation["conversation_id"],
},
)
client.post(
"/v1/chat",
json={
"conversation_id": conversation["conversation_id"],
"message": "Как отвечать?",
},
)
assert seen_messages[0][0]["role"] == "system"
assert "User prefers direct Russian answers." in seen_messages[0][0]["content"]