feat: support manual add and edit for memory facts (#1538)

* feat: support manual add and edit for memory facts

* fix: restore memory updater save helper

* fix: address memory fact review feedback

* fix: remove duplicate memory fact edit action

* docs: simplify memory fact review setup

* docs: relax memory review startup instructions

* fix: clear rebase marker in memory settings page

* fix: address memory fact review and format issues

* fix: address memory fact review feedback

* refactor: make memory fact updates explicit patch semantics

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Admire
2026-03-29 23:53:23 +08:00
committed by GitHub
parent cdb2a3a017
commit fc7de7fffe
15 changed files with 977 additions and 52 deletions
+74
View File
@@ -5,9 +5,11 @@ from pydantic import BaseModel, Field
from deerflow.agents.memory.updater import (
clear_memory_data,
create_memory_fact,
delete_memory_fact,
get_memory_data,
reload_memory_data,
update_memory_fact,
)
from deerflow.config.memory_config import get_memory_config
@@ -58,6 +60,31 @@ class MemoryResponse(BaseModel):
facts: list[Fact] = Field(default_factory=list)
def _map_memory_fact_value_error(exc: ValueError) -> HTTPException:
"""Convert updater validation errors into stable API responses."""
if exc.args and exc.args[0] == "confidence":
detail = "Invalid confidence value; must be between 0 and 1."
else:
detail = "Memory fact content cannot be empty."
return HTTPException(status_code=400, detail=detail)
class FactCreateRequest(BaseModel):
"""Request model for creating a memory fact."""
content: str = Field(..., min_length=1, description="Fact content")
category: str = Field(default="context", description="Fact category")
confidence: float = Field(default=0.5, ge=0.0, le=1.0, description="Confidence score (0-1)")
class FactPatchRequest(BaseModel):
"""PATCH request model that preserves existing values for omitted fields."""
content: str | None = Field(default=None, min_length=1, description="Fact content")
category: str | None = Field(default=None, description="Fact category")
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
class MemoryConfigResponse(BaseModel):
"""Response model for memory configuration."""
@@ -156,6 +183,28 @@ async def clear_memory() -> MemoryResponse:
return MemoryResponse(**memory_data)
@router.post(
"/memory/facts",
response_model=MemoryResponse,
summary="Create Memory Fact",
description="Create a single saved memory fact manually.",
)
async def create_memory_fact_endpoint(request: FactCreateRequest) -> MemoryResponse:
"""Create a single fact manually."""
try:
memory_data = create_memory_fact(
content=request.content,
category=request.category,
confidence=request.confidence,
)
except ValueError as exc:
raise _map_memory_fact_value_error(exc) from exc
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to create memory fact.") from exc
return MemoryResponse(**memory_data)
@router.delete(
"/memory/facts/{fact_id}",
response_model=MemoryResponse,
@@ -174,6 +223,31 @@ async def delete_memory_fact_endpoint(fact_id: str) -> MemoryResponse:
return MemoryResponse(**memory_data)
@router.patch(
"/memory/facts/{fact_id}",
response_model=MemoryResponse,
summary="Patch Memory Fact",
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
)
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest) -> MemoryResponse:
"""Partially update a single fact manually."""
try:
memory_data = update_memory_fact(
fact_id=fact_id,
content=request.content,
category=request.category,
confidence=request.confidence,
)
except ValueError as exc:
raise _map_memory_fact_value_error(exc) from exc
except KeyError as exc:
raise HTTPException(status_code=404, detail=f"Memory fact '{fact_id}' not found.") from exc
except OSError as exc:
raise HTTPException(status_code=500, detail="Failed to update memory fact.") from exc
return MemoryResponse(**memory_data)
@router.get(
"/memory/config",
response_model=MemoryConfigResponse,
+32 -8
View File
@@ -1,34 +1,58 @@
# Memory Settings Review
Use this when reviewing the Memory Settings search, filter, delete, and clear-all flow locally.
Use this when reviewing the Memory Settings add/edit flow locally with the fewest possible manual steps.
## Quick Review
1. Start DeerFlow locally.
1. Start DeerFlow locally using any working development setup you already use.
Examples:
```bash
make dev
```
or
```bash
make docker-start
```
If you already have DeerFlow running locally, you can reuse that existing setup.
2. Load the sample memory fixture.
```bash
python scripts/load_memory_sample.py
```
3. Open the app and review `Settings > Memory`.
3. Open `Settings > Memory`.
Default local URLs:
- App: `http://localhost:2026`
- Local frontend-only fallback: `http://localhost:3000`
## What To Check
## Minimal Manual Test
- Search `memory` and confirm multiple facts are matched.
- Search `Chinese` and confirm text filtering works.
- Search `workflow` and confirm category text is also searchable.
1. Click `Add fact`.
2. Create a new fact with:
- Content: `Reviewer-added memory fact`
- Category: `testing`
- Confidence: `0.88`
3. Confirm the new fact appears immediately and shows `Manual` as the source.
4. Edit the sample fact `This sample fact is intended for edit testing.` and change it to:
- Content: `This sample fact was edited during manual review.`
- Category: `testing`
- Confidence: `0.91`
5. Confirm the edited fact updates immediately.
6. Refresh the page and confirm both the newly added fact and the edited fact still persist.
## Optional Sanity Checks
- Search `Reviewer-added` and confirm the new fact is matched.
- Search `workflow` and confirm category text is searchable.
- Switch between `All`, `Facts`, and `Summaries`.
- Delete the disposable sample fact and confirm the list updates immediately.
- Delete the disposable sample fact `Delete fact testing can target this disposable sample entry.` and confirm the list updates immediately.
- Clear all memory and confirm the page enters the empty state.
## Fixture Files
+8
View File
@@ -101,6 +101,14 @@
"confidence": 0.78,
"createdAt": "2026-03-28T10:06:00Z",
"source": "thread_delete_demo"
},
{
"id": "fact_review_010",
"content": "This sample fact is intended for edit testing.",
"category": "testing",
"confidence": 0.8,
"createdAt": "2026-03-28T10:08:00Z",
"source": "manual"
}
]
}
@@ -2,6 +2,7 @@
import json
import logging
import math
import re
import uuid
from datetime import datetime
@@ -40,12 +41,54 @@ def reload_memory_data(agent_name: str | None = None) -> dict[str, Any]:
def clear_memory_data(agent_name: str | None = None) -> dict[str, Any]:
"""Clear all stored memory data and persist an empty structure."""
cleared_memory = _create_empty_memory()
cleared_memory = create_empty_memory()
if not _save_memory_to_file(cleared_memory, agent_name):
raise OSError("Failed to save cleared memory data")
return cleared_memory
def _validate_confidence(confidence: float) -> float:
"""Validate persisted fact confidence so stored JSON stays standards-compliant."""
if not math.isfinite(confidence) or confidence < 0 or confidence > 1:
raise ValueError("confidence")
return confidence
def create_memory_fact(
content: str,
category: str = "context",
confidence: float = 0.5,
agent_name: str | None = None,
) -> dict[str, Any]:
"""Create a new fact and persist the updated memory data."""
normalized_content = content.strip()
if not normalized_content:
raise ValueError("content")
normalized_category = category.strip() or "context"
validated_confidence = _validate_confidence(confidence)
now = datetime.utcnow().isoformat() + "Z"
memory_data = get_memory_data(agent_name)
updated_memory = dict(memory_data)
facts = list(memory_data.get("facts", []))
facts.append(
{
"id": f"fact_{uuid.uuid4().hex[:8]}",
"content": normalized_content,
"category": normalized_category,
"confidence": validated_confidence,
"createdAt": now,
"source": "manual",
}
)
updated_memory["facts"] = facts
if not _save_memory_to_file(updated_memory, agent_name):
raise OSError("Failed to save memory data after creating fact")
return updated_memory
def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str, Any]:
"""Delete a fact by its id and persist the updated memory data."""
memory_data = get_memory_data(agent_name)
@@ -63,6 +106,47 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None) -> dict[str,
return updated_memory
def update_memory_fact(
fact_id: str,
content: str | None = None,
category: str | None = None,
confidence: float | None = None,
agent_name: str | None = None,
) -> dict[str, Any]:
"""Update an existing fact and persist the updated memory data."""
memory_data = get_memory_data(agent_name)
updated_memory = dict(memory_data)
updated_facts: list[dict[str, Any]] = []
found = False
for fact in memory_data.get("facts", []):
if fact.get("id") == fact_id:
found = True
updated_fact = dict(fact)
if content is not None:
normalized_content = content.strip()
if not normalized_content:
raise ValueError("content")
updated_fact["content"] = normalized_content
if category is not None:
updated_fact["category"] = category.strip() or "context"
if confidence is not None:
updated_fact["confidence"] = _validate_confidence(confidence)
updated_facts.append(updated_fact)
else:
updated_facts.append(fact)
if not found:
raise KeyError(fact_id)
updated_memory["facts"] = updated_facts
if not _save_memory_to_file(updated_memory, agent_name):
raise OSError(f"Failed to save memory data after updating fact '{fact_id}'")
return updated_memory
def _extract_text(content: Any) -> str:
"""Extract plain text from LLM response content (str or list of content blocks).
@@ -688,12 +688,35 @@ class DeerFlowClient:
return clear_memory_data()
def create_memory_fact(self, content: str, category: str = "context", confidence: float = 0.5) -> dict:
"""Create a single fact manually."""
from deerflow.agents.memory.updater import create_memory_fact
return create_memory_fact(content=content, category=category, confidence=confidence)
def delete_memory_fact(self, fact_id: str) -> dict:
"""Delete a single fact from memory by fact id."""
from deerflow.agents.memory.updater import delete_memory_fact
return delete_memory_fact(fact_id)
def update_memory_fact(
self,
fact_id: str,
content: str | None = None,
category: str | None = None,
confidence: float | None = None,
) -> dict:
"""Update a single fact manually, preserving omitted fields."""
from deerflow.agents.memory.updater import update_memory_fact
return update_memory_fact(
fact_id=fact_id,
content=content,
category=category,
confidence=confidence,
)
def get_memory_config(self) -> dict:
"""Get memory system configuration.
+47
View File
@@ -673,6 +673,21 @@ class TestMemoryManagement:
result = client.clear_memory()
assert result == data
def test_create_memory_fact(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.create_memory_fact", return_value=data) as create_fact:
result = client.create_memory_fact(
"User prefers concise code reviews.",
category="preference",
confidence=0.88,
)
create_fact.assert_called_once_with(
content="User prefers concise code reviews.",
category="preference",
confidence=0.88,
)
assert result == data
def test_delete_memory_fact(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.delete_memory_fact", return_value=data) as delete_fact:
@@ -680,6 +695,38 @@ class TestMemoryManagement:
delete_fact.assert_called_once_with("fact_123")
assert result == data
def test_update_memory_fact(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact:
result = client.update_memory_fact(
"fact_123",
"User prefers spaces",
category="workflow",
confidence=0.91,
)
update_fact.assert_called_once_with(
fact_id="fact_123",
content="User prefers spaces",
category="workflow",
confidence=0.91,
)
assert result == data
def test_update_memory_fact_preserves_omitted_fields(self, client):
data = {"version": "1.0", "facts": []}
with patch("deerflow.agents.memory.updater.update_memory_fact", return_value=data) as update_fact:
result = client.update_memory_fact(
"fact_123",
"User prefers spaces",
)
update_fact.assert_called_once_with(
fact_id="fact_123",
content="User prefers spaces",
category=None,
confidence=None,
)
assert result == data
def test_get_memory_config(self, client):
config = MagicMock()
config.enabled = True
+134
View File
@@ -36,6 +36,37 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
assert response.json()["facts"] == []
def test_create_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(
facts=[
{
"id": "fact_new",
"content": "User prefers concise code reviews.",
"category": "preference",
"confidence": 0.88,
"createdAt": "2026-03-20T00:00:00Z",
"source": "manual",
}
]
)
with patch("app.gateway.routers.memory.create_memory_fact", return_value=updated_memory):
with TestClient(app) as client:
response = client.post(
"/api/memory/facts",
json={
"content": "User prefers concise code reviews.",
"category": "preference",
"confidence": 0.88,
},
)
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_delete_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
@@ -70,3 +101,106 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
assert response.status_code == 404
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
def test_update_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(
facts=[
{
"id": "fact_edit",
"content": "User prefers spaces",
"category": "workflow",
"confidence": 0.91,
"createdAt": "2026-03-20T00:00:00Z",
"source": "manual",
}
]
)
with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory):
with TestClient(app) as client:
response = client.patch(
"/api/memory/facts/fact_edit",
json={
"content": "User prefers spaces",
"category": "workflow",
"confidence": 0.91,
},
)
assert response.status_code == 200
assert response.json()["facts"] == updated_memory["facts"]
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
app = FastAPI()
app.include_router(memory.router)
updated_memory = _sample_memory(
facts=[
{
"id": "fact_edit",
"content": "User prefers spaces",
"category": "preference",
"confidence": 0.8,
"createdAt": "2026-03-20T00:00:00Z",
"source": "manual",
}
]
)
with patch("app.gateway.routers.memory.update_memory_fact", return_value=updated_memory) as update_fact:
with TestClient(app) as client:
response = client.patch(
"/api/memory/facts/fact_edit",
json={
"content": "User prefers spaces",
},
)
assert response.status_code == 200
update_fact.assert_called_once_with(
fact_id="fact_edit",
content="User prefers spaces",
category=None,
confidence=None,
)
assert response.json()["facts"] == updated_memory["facts"]
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=KeyError("fact_missing")):
with TestClient(app) as client:
response = client.patch(
"/api/memory/facts/fact_missing",
json={
"content": "User prefers spaces",
"category": "workflow",
"confidence": 0.91,
},
)
assert response.status_code == 404
assert response.json()["detail"] == "Memory fact 'fact_missing' not found."
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
app = FastAPI()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.update_memory_fact", side_effect=ValueError("confidence")):
with TestClient(app) as client:
response = client.patch(
"/api/memory/facts/fact_edit",
json={
"content": "User prefers spaces",
"confidence": 0.91,
},
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1."
+154
View File
@@ -5,7 +5,9 @@ from deerflow.agents.memory.updater import (
MemoryUpdater,
_extract_text,
clear_memory_data,
create_memory_fact,
delete_memory_fact,
update_memory_fact,
)
from deerflow.config.memory_config import MemoryConfig
@@ -184,6 +186,43 @@ def test_delete_memory_fact_removes_only_matching_fact() -> None:
assert [fact["id"] for fact in result["facts"]] == ["fact_keep"]
def test_create_memory_fact_appends_manual_fact() -> None:
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()),
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
):
result = create_memory_fact(
content=" User prefers concise code reviews. ",
category="preference",
confidence=0.88,
)
assert len(result["facts"]) == 1
assert result["facts"][0]["content"] == "User prefers concise code reviews."
assert result["facts"][0]["category"] == "preference"
assert result["facts"][0]["confidence"] == 0.88
assert result["facts"][0]["source"] == "manual"
def test_create_memory_fact_rejects_empty_content() -> None:
try:
create_memory_fact(content=" ")
except ValueError as exc:
assert exc.args == ("content",)
else:
raise AssertionError("Expected ValueError for empty fact content")
def test_create_memory_fact_rejects_invalid_confidence() -> None:
for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")):
try:
create_memory_fact(content="User likes tests", confidence=confidence)
except ValueError as exc:
assert exc.args == ("confidence",)
else:
raise AssertionError("Expected ValueError for invalid fact confidence")
def test_delete_memory_fact_raises_for_unknown_id() -> None:
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
try:
@@ -194,6 +233,121 @@ def test_delete_memory_fact_raises_for_unknown_id() -> None:
raise AssertionError("Expected KeyError for missing fact id")
def test_update_memory_fact_updates_only_matching_fact() -> None:
current_memory = _make_memory(
facts=[
{
"id": "fact_keep",
"content": "User likes Python",
"category": "preference",
"confidence": 0.9,
"createdAt": "2026-03-18T00:00:00Z",
"source": "thread-a",
},
{
"id": "fact_edit",
"content": "User prefers tabs",
"category": "preference",
"confidence": 0.8,
"createdAt": "2026-03-18T00:00:00Z",
"source": "manual",
},
]
)
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory),
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
):
result = update_memory_fact(
fact_id="fact_edit",
content="User prefers spaces",
category="workflow",
confidence=0.91,
)
assert result["facts"][0]["content"] == "User likes Python"
assert result["facts"][1]["content"] == "User prefers spaces"
assert result["facts"][1]["category"] == "workflow"
assert result["facts"][1]["confidence"] == 0.91
assert result["facts"][1]["createdAt"] == "2026-03-18T00:00:00Z"
assert result["facts"][1]["source"] == "manual"
def test_update_memory_fact_preserves_omitted_fields() -> None:
current_memory = _make_memory(
facts=[
{
"id": "fact_edit",
"content": "User prefers tabs",
"category": "preference",
"confidence": 0.8,
"createdAt": "2026-03-18T00:00:00Z",
"source": "manual",
},
]
)
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=current_memory),
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
):
result = update_memory_fact(
fact_id="fact_edit",
content="User prefers spaces",
)
assert result["facts"][0]["content"] == "User prefers spaces"
assert result["facts"][0]["category"] == "preference"
assert result["facts"][0]["confidence"] == 0.8
def test_update_memory_fact_raises_for_unknown_id() -> None:
with patch("deerflow.agents.memory.updater.get_memory_data", return_value=_make_memory()):
try:
update_memory_fact(
fact_id="fact_missing",
content="User prefers concise code reviews.",
category="preference",
confidence=0.88,
)
except KeyError as exc:
assert exc.args == ("fact_missing",)
else:
raise AssertionError("Expected KeyError for missing fact id")
def test_update_memory_fact_rejects_invalid_confidence() -> None:
current_memory = _make_memory(
facts=[
{
"id": "fact_edit",
"content": "User prefers tabs",
"category": "preference",
"confidence": 0.8,
"createdAt": "2026-03-18T00:00:00Z",
"source": "manual",
},
]
)
for confidence in (-0.1, 1.1, float("nan"), float("inf"), float("-inf")):
with patch(
"deerflow.agents.memory.updater.get_memory_data",
return_value=current_memory,
):
try:
update_memory_fact(
fact_id="fact_edit",
content="User prefers spaces",
confidence=confidence,
)
except ValueError as exc:
assert exc.args == ("confidence",)
else:
raise AssertionError("Expected ValueError for invalid fact confidence")
# ---------------------------------------------------------------------------
# _extract_text — LLM response content normalization
# ---------------------------------------------------------------------------