mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-30 01:52:29 +00:00
* refactor(steps): update naming conventions in components and configuration Updated naming conventions across multiple files, changing colon-separated names to underscore-separated format, and added new step definitions along with documentation updates. Key changes: - Replaced `Synchronizer` with `AutoMemory` as the counterpart component for cold-write operations - Updated naming conventions in all related configuration files (e.g., `frontmatter:read` → `frontmatter_read`) - Added new step definitions such as `submit_slug_updates` and `auto_memory` - Updated relevant documentation - Modified log output format for improved readability * refactor(evolve): Refactor the auto-memory module and update related configurations - Remove the old slug update commit step file - Add new auto-memory planner and writer steps - Update __init__.py to export the new step classes - Modify the auto_memory configuration structure in default.yaml - Update the slug field description for clearer explanation of its purpose * up * up * refactor(tests): Move unit test directory from `tests4/unittest` to `tests4/unit` Additionally, the assertion logic in test files has been updated: direct comparisons of `payload["notes"]` have been replaced with checks verifying the presence of paths and metadata within the response content. Furthermore, some test expectations have been simplified—for example, using `count` instead of asserting against specific note lists. Specific changes include: - Updating workflow configurations to align with the new test directory structure - Modifying assertions across multiple test methods to make them more flexible and maintainable - Cleaning up and optimizing parts of the test code structure This is a comprehensive test refactoring effort aimed at improving test readability and robustness. * Refactor(steps): Update memory writing logic and optimize JSON schema structure Improved the write strategy description in `auto_memory_writer.yaml` to emphasize using `edit` over `write`. Adjusted the `json_schema` structure in `base_step.py` to support the new function definition format. Also corrected grammatical issues in the related documentation. * Fix: Improve frontend data parsing error handling and update test files Added capture and handling logic for YAML parsing exceptions, providing more detailed error messages when frontend data format issues occur. Also corrected the description text in a test file.
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""Tests for Neo4jFileGraph.
|
|
|
|
Skipped automatically if (a) the ``neo4j`` driver isn't installed,
|
|
or (b) a Neo4j instance isn't reachable at the configured URI.
|
|
|
|
Override the URI / auth via env vars:
|
|
NEO4J_URI (default ``bolt://localhost:7687``)
|
|
NEO4J_USER (default ``neo4j``)
|
|
NEO4J_PASSWORD (default ``neo4j``)
|
|
NEO4J_DATABASE (default ``neo4j``)
|
|
"""
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
from reme4.schema import FileLink, FileNode
|
|
|
|
|
|
URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
|
USER = os.environ.get("NEO4J_USER", "neo4j")
|
|
PASSWORD = os.environ.get("NEO4J_PASSWORD", "neo4j")
|
|
DATABASE = os.environ.get("NEO4J_DATABASE", "neo4j")
|
|
|
|
|
|
try:
|
|
from reme4.components.file_graph import Neo4jFileGraph
|
|
|
|
_NEO4J_IMPORT_ERROR: Exception | None = None
|
|
except Exception as e: # pragma: no cover
|
|
_NEO4J_IMPORT_ERROR = e
|
|
|
|
|
|
async def _probe_neo4j() -> str | None:
|
|
"""Try to connect; return None if OK, error string if unreachable."""
|
|
if _NEO4J_IMPORT_ERROR is not None:
|
|
return f"import failed: {_NEO4J_IMPORT_ERROR}"
|
|
try:
|
|
from neo4j import AsyncGraphDatabase
|
|
except ImportError as e:
|
|
return f"neo4j driver not installed: {e}"
|
|
try:
|
|
driver = AsyncGraphDatabase.driver(URI, auth=(USER, PASSWORD))
|
|
async with driver.session(database=DATABASE) as session:
|
|
await session.run("RETURN 1")
|
|
await driver.close()
|
|
return None
|
|
except Exception as e: # pragma: no cover
|
|
return f"connect failed: {e}"
|
|
|
|
|
|
_PROBE_NOT_RUN = object()
|
|
_PROBE_REASON: str | None | object = _PROBE_NOT_RUN # sentinel: "not probed"
|
|
|
|
|
|
def _probe_reason() -> str | None:
|
|
"""Probe Neo4j once per process; cache the outcome."""
|
|
global _PROBE_REASON
|
|
if _PROBE_REASON is _PROBE_NOT_RUN:
|
|
_PROBE_REASON = asyncio.run(_probe_neo4j())
|
|
return _PROBE_REASON # type: ignore[return-value]
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
_probe_reason() is not None,
|
|
reason=f"Neo4j unavailable: {_probe_reason()}",
|
|
)
|
|
|
|
|
|
class temp_chdir:
|
|
"""Context manager to temporarily chdir into a path and restore on exit."""
|
|
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self.old = None
|
|
|
|
def __enter__(self):
|
|
self.old = os.getcwd()
|
|
os.chdir(self.path)
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
os.chdir(self.old)
|
|
|
|
|
|
def make_node(path: str, links: list[tuple[str, str | None]] | None = None) -> FileNode:
|
|
"""Build a FileNode with outgoing (target_path, target_anchor) pairs."""
|
|
return FileNode(
|
|
path=path,
|
|
st_mtime=1.0,
|
|
links=[FileLink(source_path=path, target_path=t, target_anchor=a) for t, a in (links or [])],
|
|
)
|
|
|
|
|
|
async def _fresh_graph() -> "Neo4jFileGraph": # type: ignore[name-defined]
|
|
"""Build a started Neo4jFileGraph wiped clean."""
|
|
graph = Neo4jFileGraph(uri=URI, user=USER, password=PASSWORD, database=DATABASE)
|
|
await graph.start()
|
|
await graph.clear()
|
|
return graph
|
|
|
|
|
|
def test_upsert_and_get_nodes():
|
|
"""upsert_nodes stores; get_nodes returns by paths or all."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[make_node("a.md", [("b.md", None)]), make_node("b.md")],
|
|
)
|
|
got_all = await graph.get_nodes()
|
|
assert {n.path for n in got_all} == {"a.md", "b.md"}
|
|
got_one = await graph.get_nodes(["a.md"])
|
|
assert len(got_one) == 1 and got_one[0].path == "a.md"
|
|
assert await graph.get_nodes(["nope.md"]) == []
|
|
assert await graph.get_nodes([]) == []
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_upsert_and_get_nodes passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_outlinks_skip_virtual_targets():
|
|
"""get_outlinks excludes edges into virtual placeholder nodes."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[
|
|
make_node("a.md", [("b.md", None), ("ghost.md", None)]),
|
|
make_node("b.md"),
|
|
],
|
|
)
|
|
outs = await graph.get_outlinks("a.md")
|
|
assert {link.target_path for link in outs} == {"b.md"}
|
|
for link in outs:
|
|
assert link.source_path == "a.md"
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_outlinks_skip_virtual_targets passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_inlinks_carry_source_path():
|
|
"""get_inlinks returns FileLinks whose source_path is the linking node."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[
|
|
make_node("a.md", [("b.md", "anchor1")]),
|
|
make_node("c.md", [("b.md", None)]),
|
|
make_node("b.md"),
|
|
],
|
|
)
|
|
ins = await graph.get_inlinks("b.md")
|
|
sources = {link.source_path for link in ins}
|
|
assert sources == {"a.md", "c.md"}
|
|
# Each link's target should be the queried path.
|
|
for link in ins:
|
|
assert link.target_path == "b.md"
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_inlinks_carry_source_path passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_delete_demotes_then_repromotes():
|
|
"""delete_nodes makes a node virtual; re-upsert promotes pending edges back."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[make_node("a.md", [("b.md", None)]), make_node("b.md")],
|
|
)
|
|
assert {link.source_path for link in await graph.get_inlinks("b.md")} == {"a.md"}
|
|
|
|
await graph.delete_nodes(["b.md"])
|
|
assert await graph.get_nodes(["b.md"]) == []
|
|
# a's outlink is hidden because b is now virtual.
|
|
assert await graph.get_outlinks("a.md") == []
|
|
|
|
await graph.upsert_nodes([make_node("b.md")])
|
|
assert {link.source_path for link in await graph.get_inlinks("b.md")} == {"a.md"}
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_delete_demotes_then_repromotes passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_rebuild_links_idempotent():
|
|
"""rebuild_links reconstructs identical out/in views from per-node payloads."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[
|
|
make_node("a.md", [("b.md", None), ("c.md", "h")]),
|
|
make_node("b.md"),
|
|
make_node("c.md"),
|
|
],
|
|
)
|
|
before_out = sorted((link.target_path, link.target_anchor) for link in await graph.get_outlinks("a.md"))
|
|
before_in_b = sorted(link.source_path for link in await graph.get_inlinks("b.md"))
|
|
|
|
await graph.rebuild_links()
|
|
|
|
after_out = sorted((link.target_path, link.target_anchor) for link in await graph.get_outlinks("a.md"))
|
|
after_in_b = sorted(link.source_path for link in await graph.get_inlinks("b.md"))
|
|
assert before_out == after_out
|
|
assert before_in_b == after_in_b
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_rebuild_links_idempotent passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_clear_wipes_everything():
|
|
"""clear() drops every node and edge in the database."""
|
|
|
|
async def run():
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
await graph.upsert_nodes(
|
|
[make_node("a.md", [("b.md", None)]), make_node("b.md")],
|
|
)
|
|
await graph.clear()
|
|
assert await graph.get_nodes() == []
|
|
finally:
|
|
await graph.close()
|
|
print("✓ test_clear_wipes_everything passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_node_roundtrip_preserves_frontmatter_and_links():
|
|
"""Upsert → get_nodes round-trip preserves frontmatter + links + chunk_ids."""
|
|
|
|
async def run():
|
|
from reme4.schema.file_node import FileFrontMatter
|
|
|
|
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
|
|
graph = await _fresh_graph()
|
|
try:
|
|
node = FileNode(
|
|
path="topics/Alice.md",
|
|
st_mtime=1234.5,
|
|
links=[
|
|
FileLink(
|
|
source_path="topics/Alice.md",
|
|
target_path="topics/Bob.md",
|
|
target_anchor="intro",
|
|
predicate="knows",
|
|
),
|
|
],
|
|
chunk_ids=["chunk-a1", "chunk-a2", "chunk-a3"],
|
|
front_matter=FileFrontMatter(
|
|
name="Alice",
|
|
description="a person",
|
|
),
|
|
)
|
|
await graph.upsert_nodes([node, make_node("topics/Bob.md")])
|
|
got = await graph.get_nodes(["topics/Alice.md"])
|
|
assert len(got) == 1
|
|
back = got[0]
|
|
assert back.path == "topics/Alice.md"
|
|
assert back.st_mtime == 1234.5
|
|
assert back.front_matter.name == "Alice"
|
|
assert back.front_matter.description == "a person"
|
|
assert back.chunk_ids == ["chunk-a1", "chunk-a2", "chunk-a3"]
|
|
assert len(back.links) == 1
|
|
link = back.links[0]
|
|
assert link.source_path == "topics/Alice.md"
|
|
assert link.target_path == "topics/Bob.md"
|
|
assert link.target_anchor == "intro"
|
|
assert link.predicate == "knows"
|
|
|
|
# An upsert with empty chunk_ids should also round-trip cleanly
|
|
# (and overwrite the previous list).
|
|
await graph.upsert_nodes(
|
|
[
|
|
FileNode(
|
|
path="topics/Alice.md",
|
|
st_mtime=1234.5,
|
|
chunk_ids=[],
|
|
),
|
|
],
|
|
)
|
|
got2 = await graph.get_nodes(["topics/Alice.md"])
|
|
assert got2 and got2[0].chunk_ids == []
|
|
finally:
|
|
await graph.clear()
|
|
await graph.close()
|
|
print("✓ test_node_roundtrip_preserves_frontmatter_and_links passed")
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if _probe_reason() is not None:
|
|
print(f"Skipping Neo4j tests: {_probe_reason()}")
|
|
else:
|
|
print("\n=== Neo4jFileGraph tests ===")
|
|
test_upsert_and_get_nodes()
|
|
test_outlinks_skip_virtual_targets()
|
|
test_inlinks_carry_source_path()
|
|
test_delete_demotes_then_repromotes()
|
|
test_rebuild_links_idempotent()
|
|
test_clear_wipes_everything()
|
|
test_node_roundtrip_preserves_frontmatter_and_links()
|
|
print("\n所有测试通过!")
|