Add audit and memory UI
This commit is contained in:
parent
61cdd8bbaf
commit
3d815d3ae3
|
|
@ -6,7 +6,7 @@ from typing import Any
|
|||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -17,6 +17,7 @@ from duck_core.conversations.store import ConversationStore
|
|||
from duck_core.events.store import EventStore
|
||||
from duck_core.experience.recorder import ExperienceRecorder
|
||||
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
|
||||
from duck_core.memory.store import MemoryStore
|
||||
from duck_core.model_client import ModelClient
|
||||
from duck_core.runtime_loop import RuntimeLoop
|
||||
from duck_core.skills.registry import SkillRegistry
|
||||
|
|
@ -46,6 +47,15 @@ class PasswordRequest(BaseModel):
|
|||
password: str
|
||||
|
||||
|
||||
class MemoryRequest(BaseModel):
|
||||
text: str
|
||||
workspace: str | None = None
|
||||
conversation_id: str | None = None
|
||||
memory_type: str = "note"
|
||||
importance: float = 0.5
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
|
|
@ -69,6 +79,7 @@ def create_app() -> FastAPI:
|
|||
skills = SkillRegistry("skills")
|
||||
experience = ExperienceRecorder(settings.db_path)
|
||||
memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None)
|
||||
memory_store = MemoryStore(settings.db_path)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
|
|
@ -77,11 +88,16 @@ def create_app() -> FastAPI:
|
|||
await conversations.init()
|
||||
await approvals.init()
|
||||
await experience.init()
|
||||
await memory_store.init()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> FileResponse:
|
||||
return FileResponse("favicon.ico")
|
||||
|
||||
@app.get("/approvals", response_class=HTMLResponse)
|
||||
async def approvals_page(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "approvals.html")
|
||||
|
|
@ -796,8 +812,34 @@ def create_app() -> FastAPI:
|
|||
raise HTTPException(status_code=404, detail="Experience record not found")
|
||||
return record.model_dump()
|
||||
|
||||
@app.post("/v1/memory")
|
||||
async def add_memory(body: MemoryRequest) -> dict[str, Any]:
|
||||
if not body.text.strip():
|
||||
raise HTTPException(status_code=400, detail="Memory text is required")
|
||||
record = await memory_store.add(
|
||||
text=body.text,
|
||||
workspace=body.workspace or settings.workspace,
|
||||
conversation_id=body.conversation_id,
|
||||
memory_type=body.memory_type,
|
||||
importance=body.importance,
|
||||
metadata=body.metadata,
|
||||
)
|
||||
return record.model_dump()
|
||||
|
||||
@app.get("/v1/memory")
|
||||
async def list_memory(
|
||||
workspace: str | None = None, limit: int = 50
|
||||
) -> dict[str, Any]:
|
||||
records = await memory_store.list(workspace=workspace, limit=limit)
|
||||
return {"results": [record.model_dump() for record in records]}
|
||||
|
||||
@app.get("/v1/memory/search")
|
||||
async def search_memory(q: str) -> dict[str, Any]:
|
||||
async def search_memory(
|
||||
q: str, workspace: str | None = None, limit: int = 20
|
||||
) -> dict[str, Any]:
|
||||
local_results = await memory_store.search(q, workspace=workspace, limit=limit)
|
||||
if local_results:
|
||||
return {"results": [record.model_dump() for record in local_results]}
|
||||
try:
|
||||
return {"results": await memory.search_memory(q)}
|
||||
except EmbeddingsUnavailableError as exc:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import aiosqlite
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from duck_core.tasks.store import utc_now
|
||||
|
||||
|
||||
class MemoryRecord(BaseModel):
|
||||
id: int | None = None
|
||||
memory_id: str
|
||||
text: str
|
||||
workspace: str
|
||||
conversation_id: str | None = None
|
||||
memory_type: str = "note"
|
||||
importance: float = 0.5
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class MemoryStore:
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
async def init(self) -> None:
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
create table if not exists memories (
|
||||
id integer primary key autoincrement,
|
||||
memory_id text not null unique,
|
||||
text text not null,
|
||||
workspace text not null,
|
||||
conversation_id text,
|
||||
memory_type text not null,
|
||||
importance real not null,
|
||||
metadata_json text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
create index if not exists idx_memories_workspace_created
|
||||
on memories(workspace, created_at)
|
||||
"""
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def add(
|
||||
self,
|
||||
text: str,
|
||||
workspace: str,
|
||||
conversation_id: str | None = None,
|
||||
memory_type: str = "note",
|
||||
importance: float = 0.5,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> MemoryRecord:
|
||||
await self.init()
|
||||
now = utc_now()
|
||||
memory_id = f"mem_{uuid4().hex[:12]}"
|
||||
clean_text = " ".join(text.strip().split())
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
insert into memories(
|
||||
memory_id, text, workspace, conversation_id, memory_type,
|
||||
importance, metadata_json, created_at, updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
memory_id,
|
||||
clean_text,
|
||||
workspace,
|
||||
conversation_id,
|
||||
memory_type or "note",
|
||||
max(0.0, min(float(importance), 1.0)),
|
||||
json.dumps(metadata or {}, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
row_id = cursor.lastrowid
|
||||
return MemoryRecord(
|
||||
id=row_id,
|
||||
memory_id=memory_id,
|
||||
text=clean_text,
|
||||
workspace=workspace,
|
||||
conversation_id=conversation_id,
|
||||
memory_type=memory_type or "note",
|
||||
importance=max(0.0, min(float(importance), 1.0)),
|
||||
metadata=metadata or {},
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
async def list(
|
||||
self, workspace: str | None = None, limit: int = 50
|
||||
) -> list[MemoryRecord]:
|
||||
await self.init()
|
||||
bounded_limit = min(max(limit, 1), 200)
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
if workspace:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select * from memories
|
||||
where workspace = ?
|
||||
order by importance desc, created_at desc
|
||||
limit ?
|
||||
""",
|
||||
(workspace, bounded_limit),
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select * from memories
|
||||
order by importance desc, created_at desc
|
||||
limit ?
|
||||
""",
|
||||
(bounded_limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_record(row) for row in rows]
|
||||
|
||||
async def search(
|
||||
self, query: str, workspace: str | None = None, limit: int = 20
|
||||
) -> list[MemoryRecord]:
|
||||
await self.init()
|
||||
bounded_limit = min(max(limit, 1), 100)
|
||||
pattern = f"%{query.strip()}%"
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
if workspace:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select * from memories
|
||||
where workspace = ?
|
||||
and (text like ? or memory_type like ? or metadata_json like ?)
|
||||
order by importance desc, created_at desc
|
||||
limit ?
|
||||
""",
|
||||
(workspace, pattern, pattern, pattern, bounded_limit),
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
select * from memories
|
||||
where text like ? or memory_type like ? or metadata_json like ?
|
||||
order by importance desc, created_at desc
|
||||
limit ?
|
||||
""",
|
||||
(pattern, pattern, pattern, bounded_limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [self._row_to_record(row) for row in rows]
|
||||
|
||||
def _row_to_record(self, row: aiosqlite.Row) -> MemoryRecord:
|
||||
return MemoryRecord(
|
||||
id=row["id"],
|
||||
memory_id=row["memory_id"],
|
||||
text=row["text"],
|
||||
workspace=row["workspace"],
|
||||
conversation_id=row["conversation_id"],
|
||||
memory_type=row["memory_type"],
|
||||
importance=float(row["importance"]),
|
||||
metadata=json.loads(row["metadata_json"]),
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
|
@ -3,6 +3,7 @@ const state = {
|
|||
messages: [],
|
||||
currentConversationId: "",
|
||||
conversations: [],
|
||||
auditFilter: "all",
|
||||
};
|
||||
|
||||
async function jsonFetch(url, options) {
|
||||
|
|
@ -372,6 +373,81 @@ async function refreshEvents(taskId) {
|
|||
return events;
|
||||
}
|
||||
|
||||
async function refreshCommandAudit() {
|
||||
const container = document.querySelector("#command-audit");
|
||||
if (!container) return;
|
||||
const events = await jsonFetch("/v1/audit/commands?limit=20");
|
||||
container.innerHTML = "";
|
||||
if (!events.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "compact-empty";
|
||||
empty.textContent = "No command events yet.";
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const event of events) {
|
||||
const payload = event.payload || {};
|
||||
const item = document.createElement("article");
|
||||
item.className = "audit-item";
|
||||
|
||||
const command = document.createElement("code");
|
||||
command.textContent = payload.command || "shell command";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "audit-meta";
|
||||
meta.append(
|
||||
auditBadge(payload.action_type || "shell_command"),
|
||||
auditBadge(payload.risk_level || "unknown", payload.risk_level),
|
||||
auditBadge(payload.ok ? "ok" : "failed", payload.ok ? "ok" : "bad"),
|
||||
);
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = [
|
||||
payload.approved ? "approved" : "direct",
|
||||
payload.blocked ? "blocked" : "",
|
||||
payload.returncode !== null && payload.returncode !== undefined ? `exit ${payload.returncode}` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
item.append(command, meta, detail);
|
||||
container.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function auditBadge(text, tone = "") {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "audit-badge";
|
||||
badge.textContent = text;
|
||||
if (tone) badge.dataset.tone = tone;
|
||||
return badge;
|
||||
}
|
||||
|
||||
async function refreshMemory(query = "") {
|
||||
const container = document.querySelector("#memory-list");
|
||||
if (!container) return;
|
||||
const workspace = document.querySelector("#workspace")?.value || "./workspace";
|
||||
const url = query.trim()
|
||||
? `/v1/memory/search?q=${encodeURIComponent(query.trim())}&workspace=${encodeURIComponent(workspace)}`
|
||||
: `/v1/memory?workspace=${encodeURIComponent(workspace)}&limit=20`;
|
||||
const payload = await jsonFetch(url);
|
||||
const results = payload.results || [];
|
||||
container.innerHTML = "";
|
||||
if (!results.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "compact-empty";
|
||||
empty.textContent = query.trim() ? "No matching memories." : "No workspace memories.";
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const memory of results) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "memory-item";
|
||||
const text = document.createElement("p");
|
||||
text.textContent = memory.text;
|
||||
const meta = document.createElement("span");
|
||||
meta.textContent = `${memory.memory_type || "note"} · ${Number(memory.importance || 0).toFixed(1)} · ${memory.created_at || ""}`;
|
||||
item.append(text, meta);
|
||||
container.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeEvent(payload) {
|
||||
if (!payload || typeof payload !== "object") return "";
|
||||
if (payload.role && payload.latency_ms) {
|
||||
|
|
@ -496,6 +572,8 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
|
|||
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
|
||||
finishInlineReasoning(pending, data.reasoning_content);
|
||||
await refreshEvents(data.task_id);
|
||||
await refreshCommandAudit();
|
||||
await refreshMemory();
|
||||
await refreshConversations();
|
||||
return;
|
||||
}
|
||||
|
|
@ -592,6 +670,7 @@ async function selectConversation(conversationId) {
|
|||
for (const message of conversation.messages) addStoredMessage(message);
|
||||
}
|
||||
await refreshConversations();
|
||||
await refreshMemory();
|
||||
}
|
||||
|
||||
async function createNewConversation() {
|
||||
|
|
@ -677,6 +756,34 @@ function bindChat() {
|
|||
document.querySelector("#reload-chat")?.addEventListener("click", () => {
|
||||
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
|
||||
});
|
||||
document.querySelector("#refresh-audit")?.addEventListener("click", () => {
|
||||
refreshCommandAudit().catch(console.error);
|
||||
});
|
||||
document.querySelector("#refresh-memory")?.addEventListener("click", () => {
|
||||
refreshMemory().catch(console.error);
|
||||
});
|
||||
document.querySelector("#memory-search-button")?.addEventListener("click", () => {
|
||||
refreshMemory(document.querySelector("#memory-search-inline")?.value || "").catch(console.error);
|
||||
});
|
||||
document.querySelector("#memory-form")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = document.querySelector("#memory-text");
|
||||
const text = input?.value.trim() || "";
|
||||
if (!text) return;
|
||||
await jsonFetch("/v1/memory", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
workspace: document.querySelector("#workspace")?.value || "./workspace",
|
||||
conversation_id: state.currentConversationId || null,
|
||||
memory_type: "note",
|
||||
importance: 0.6,
|
||||
}),
|
||||
});
|
||||
input.value = "";
|
||||
await refreshMemory();
|
||||
});
|
||||
document.querySelector("#conversation-list")?.addEventListener("click", (event) => {
|
||||
const item = event.target.closest("[data-conversation-id]");
|
||||
if (item) selectConversation(item.dataset.conversationId).catch(console.error);
|
||||
|
|
@ -798,6 +905,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () =>
|
|||
bindChat();
|
||||
checkRuntime();
|
||||
loadSimplePages().catch(console.error);
|
||||
refreshCommandAudit().catch(console.error);
|
||||
refreshMemory().catch(console.error);
|
||||
refreshConversations().then(() => {
|
||||
if (state.conversations[0]) {
|
||||
return selectConversation(state.conversations[0].conversation_id);
|
||||
|
|
|
|||
|
|
@ -683,7 +683,7 @@ dd {
|
|||
|
||||
.debug-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.8fr) minmax(280px, 0.85fr);
|
||||
gap: 16px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
|
@ -701,6 +701,30 @@ dd {
|
|||
font-size: 13px;
|
||||
}
|
||||
|
||||
.debug-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.debug-heading h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mini-button,
|
||||
.memory-form button,
|
||||
.memory-search-row button {
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
background: #edf2f7;
|
||||
color: #1f2937;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
pre,
|
||||
#events {
|
||||
margin: 0;
|
||||
|
|
@ -728,6 +752,97 @@ pre,
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.audit-list,
|
||||
.memory-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 170px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audit-item,
|
||||
.memory-item {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.audit-item code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.audit-badge {
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.audit-badge[data-tone="low"],
|
||||
.audit-badge[data-tone="ok"] {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.audit-badge[data-tone="medium"],
|
||||
.audit-badge[data-tone="high"] {
|
||||
background: #fef3c7;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.audit-badge[data-tone="critical"],
|
||||
.audit-badge[data-tone="bad"] {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.audit-item > span,
|
||||
.memory-item span,
|
||||
.compact-empty {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.memory-form,
|
||||
.memory-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.memory-form input,
|
||||
.memory-search-row input {
|
||||
min-width: 0;
|
||||
padding: 8px 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.memory-item p {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -789,7 +904,6 @@ pre,
|
|||
}
|
||||
|
||||
.chat-header,
|
||||
.debug-panel,
|
||||
.composer-actions {
|
||||
grid-template-columns: 1fr;
|
||||
flex-direction: column;
|
||||
|
|
@ -798,5 +912,6 @@ pre,
|
|||
|
||||
.debug-panel {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,28 @@
|
|||
<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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
from fastapi.testclient import TestClient
|
||||
|
||||
from duck_core.api import create_app
|
||||
from duck_core.memory.store import MemoryStore
|
||||
|
||||
|
||||
def test_memory_api_stores_workspace_scoped_notes(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
client = TestClient(create_app())
|
||||
|
||||
first = client.post(
|
||||
"/v1/memory",
|
||||
json={
|
||||
"text": "User prefers concise Russian answers.",
|
||||
"workspace": "/tmp/project-a",
|
||||
"conversation_id": "chat_a",
|
||||
"memory_type": "preference",
|
||||
"importance": 0.8,
|
||||
},
|
||||
).json()
|
||||
client.post(
|
||||
"/v1/memory",
|
||||
json={
|
||||
"text": "Different workspace note.",
|
||||
"workspace": "/tmp/project-b",
|
||||
"conversation_id": "chat_b",
|
||||
},
|
||||
)
|
||||
|
||||
listed = client.get("/v1/memory", params={"workspace": "/tmp/project-a"}).json()
|
||||
search = client.get(
|
||||
"/v1/memory/search",
|
||||
params={"q": "concise Russian", "workspace": "/tmp/project-a"},
|
||||
).json()
|
||||
|
||||
assert first["memory_id"].startswith("mem_")
|
||||
assert [item["text"] for item in listed["results"]] == [
|
||||
"User prefers concise Russian answers."
|
||||
]
|
||||
assert search["results"][0]["memory_id"] == first["memory_id"]
|
||||
assert search["results"][0]["memory_type"] == "preference"
|
||||
assert "Different workspace note." not in str(search)
|
||||
|
||||
|
||||
async def test_memory_store_searches_text_and_metadata(tmp_path):
|
||||
store = MemoryStore(str(tmp_path / "duck.sqlite3"))
|
||||
await store.init()
|
||||
await store.add(
|
||||
text="RX580 should use Vulkan builds.",
|
||||
workspace="/tmp/duck",
|
||||
conversation_id="chat_runtime",
|
||||
memory_type="fact",
|
||||
importance=0.9,
|
||||
metadata={"topic": "gpu"},
|
||||
)
|
||||
|
||||
results = await store.search("vulkan", workspace="/tmp/duck")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].workspace == "/tmp/duck"
|
||||
assert results[0].metadata["topic"] == "gpu"
|
||||
Loading…
Reference in New Issue