mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-14 09:01:22 +00:00
Merge c89b28a7e2 into e3fade0a78
This commit is contained in:
commit
dba7e31360
5 changed files with 113 additions and 8 deletions
|
|
@ -287,6 +287,17 @@ The `results` list is empty and `counts.returned` is `0`. An optional `response.
|
|||
The field also contains a human-readable `message`. Programmatic consumers should use `reason` to distinguish the
|
||||
outcomes. Normal hits omit `empty_result` and keep their existing answer format.
|
||||
|
||||
## Traverse return format
|
||||
|
||||
`TraverseStep` returns a readable summary of reached nodes and directed edges in `response.answer`. Node lines include
|
||||
available frontmatter names and concise descriptions; an unindexed path is marked as lacking indexed frontmatter while noting
|
||||
that the file may still exist on disk. When no edges are reached within the requested depth and direction, the answer
|
||||
says so explicitly.
|
||||
|
||||
The complete structured `TraverseGraph` is available in `response.metadata.graph`. This changes the type of
|
||||
`response.answer` from a graph dictionary to a string. Programmatic callers that parsed the old answer should migrate
|
||||
to `response.metadata.graph` for seeds, direction, depth, nodes, and edges.
|
||||
|
||||
To show the explanation through the running service's CLI client, enable the existing metadata display option:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -264,6 +264,12 @@ Memory Search 的“渐进式”不是一次把全库内容塞进结果,而是
|
|||
该字段还包含便于阅读的 `message`。程序调用方应通过 `reason` 区分两种情况。
|
||||
正常命中不包含 `empty_result`,正文格式保持原样。
|
||||
|
||||
## Traverse 返回格式
|
||||
|
||||
`TraverseStep` 会在 `response.answer` 中返回易读的已到达节点和有向边摘要。节点行会显示可用的 frontmatter 名称和简短描述;未索引路径会标明尚无索引 frontmatter,并说明文件仍可能存在于磁盘。当请求的深度和方向内没有到达的边时,答案会明确说明。
|
||||
|
||||
完整结构化 `TraverseGraph` 保存在 `response.metadata.graph`。这会将 `response.answer` 从图字典改为字符串。原先解析旧答案的程序调用方应迁移到 `response.metadata.graph`,读取 seeds、方向、深度、节点和边。
|
||||
|
||||
通过 CLI 客户端调用已启动的服务时,可以开启已有的元数据显示选项查看原因:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ jobs:
|
|||
traverse:
|
||||
backend: base
|
||||
description: >-
|
||||
Return a bounded wikilink graph with frontend-ready nodes and directed edges.
|
||||
Return a readable bounded wikilink summary in answer and the complete structured graph in metadata.graph.
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class _TraversalLink:
|
|||
|
||||
Adjacency = dict[str, list[_TraversalLink]]
|
||||
EdgeKey = tuple[str, str, str | None]
|
||||
_MAX_DESCRIPTION_CHARS = 160
|
||||
|
||||
|
||||
def _build_adjacency(nodes: list[FileNode]) -> tuple[Adjacency, Adjacency]:
|
||||
|
|
@ -138,9 +139,34 @@ def _build_graph(
|
|||
)
|
||||
|
||||
|
||||
def _render_answer(graph: TraverseGraph) -> str:
|
||||
"""Render reached nodes and edges as a self-contained agent-readable summary."""
|
||||
lines = [
|
||||
f"Traverse results (seeds={', '.join(graph.seeds)}; depth={graph.depth}; direction={graph.direction})",
|
||||
"Nodes:",
|
||||
]
|
||||
for node in graph.nodes:
|
||||
label = f" — {node.name}" if node.name else ""
|
||||
brief_description = " ".join(node.description.split())
|
||||
if len(brief_description) > _MAX_DESCRIPTION_CHARS:
|
||||
brief_description = f"{brief_description[: _MAX_DESCRIPTION_CHARS - 3].rstrip()}..."
|
||||
description = f": {brief_description}" if brief_description else ""
|
||||
availability = " (no indexed FileNode/frontmatter; file may still exist on disk)" if not node.indexed else ""
|
||||
lines.append(f"[{node.depth}] {node.path}{label}{description}{availability}")
|
||||
|
||||
lines.append("Edges:")
|
||||
if graph.edges:
|
||||
for edge in graph.edges:
|
||||
target = f"{edge.target}#{edge.target_anchor}" if edge.target_anchor else edge.target
|
||||
lines.append(f"{edge.source} -> {target} (depth={edge.depth})")
|
||||
else:
|
||||
lines.append("(no edges reached within requested depth and direction)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@R.register("traverse_step")
|
||||
class TraverseStep(BaseStep):
|
||||
"""Return a bounded wikilink graph rooted at one or more workspace paths."""
|
||||
"""Return a readable bounded traversal answer and its structured graph in metadata."""
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
|
|
@ -173,7 +199,8 @@ class TraverseStep(BaseStep):
|
|||
)
|
||||
|
||||
self.context.response.success = True
|
||||
self.context.response.answer = graph.model_dump()
|
||||
self.context.response.answer = _render_answer(graph)
|
||||
self.context.response.metadata["graph"] = graph.model_dump()
|
||||
self.logger.info(
|
||||
f"[{self.name}] seeds={seeds!r} depth={max_depth} direction={direction} "
|
||||
f"nodes={len(graph.nodes)} edges={len(graph.edges)}",
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ async def _make_store(nodes: list[FileNode]) -> LocalFileStore:
|
|||
|
||||
|
||||
def _edges(step) -> list[dict]:
|
||||
return step.context.response.answer["edges"]
|
||||
return step.context.response.metadata["graph"]["edges"]
|
||||
|
||||
|
||||
def _graph(response) -> TraverseGraph:
|
||||
return TraverseGraph.model_validate(response.metadata["graph"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -435,13 +439,14 @@ async def test_traverse_both_preserves_reciprocal_edge_directions():
|
|||
)
|
||||
step = traverse_mod.TraverseStep(file_store=store)
|
||||
response = await step(path="a.md", direction="both", depth=1)
|
||||
graph = TraverseGraph.model_validate(response.answer)
|
||||
graph = _graph(response)
|
||||
|
||||
assert {(edge.source, edge.target) for edge in graph.edges} == {
|
||||
("a.md", "b.md"),
|
||||
("b.md", "a.md"),
|
||||
}
|
||||
assert response.metadata == {}
|
||||
assert response.answer.startswith("Traverse results (seeds=a.md; depth=1; direction=both)")
|
||||
assert response.metadata["graph"] == graph.model_dump()
|
||||
await store.close()
|
||||
|
||||
|
||||
|
|
@ -462,7 +467,7 @@ async def test_traverse_returns_frontmatter_and_unresolved_nodes():
|
|||
)
|
||||
step = traverse_mod.TraverseStep(file_store=store)
|
||||
response = await step(path="a.md", direction="forward", depth=1)
|
||||
graph = TraverseGraph.model_validate(response.answer)
|
||||
graph = _graph(response)
|
||||
nodes = {node.path: node for node in graph.nodes}
|
||||
|
||||
assert graph.version == 1
|
||||
|
|
@ -478,6 +483,8 @@ async def test_traverse_returns_frontmatter_and_unresolved_nodes():
|
|||
assert nodes["missing.md"].indexed is False
|
||||
assert nodes["missing.md"].depth == 1
|
||||
assert graph.edges[0].target_anchor == "details"
|
||||
assert "[0] a.md — Alpha: Root note" in response.answer
|
||||
assert "[1] missing.md (no indexed FileNode/frontmatter; file may still exist on disk)" in response.answer
|
||||
await store.close()
|
||||
|
||||
|
||||
|
|
@ -489,8 +496,62 @@ async def test_traverse_depth_zero_returns_only_seed_nodes():
|
|||
store = await _make_store([_node("a.md", [("b.md", None)]), _node("b.md")])
|
||||
step = traverse_mod.TraverseStep(file_store=store)
|
||||
response = await step(path="a.md", direction="forward", depth=0)
|
||||
graph = TraverseGraph.model_validate(response.answer)
|
||||
graph = _graph(response)
|
||||
|
||||
assert [node.path for node in graph.nodes] == ["a.md"]
|
||||
assert graph.edges == []
|
||||
assert response.answer.endswith("(no edges reached within requested depth and direction)")
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_answer_includes_labels_descriptions_and_reached_edges():
|
||||
"""The answer is useful by itself while metadata retains the complete graph."""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
|
||||
store = await _make_store(
|
||||
[
|
||||
_node(
|
||||
"a.md",
|
||||
[("b.md", None), ("c.md", "intro")],
|
||||
name="Seed",
|
||||
description="Root note",
|
||||
),
|
||||
_node("b.md", name="Branch", description="First hop"),
|
||||
_node("c.md", name="Child", description="Anchored result"),
|
||||
],
|
||||
)
|
||||
step = traverse_mod.TraverseStep(file_store=store)
|
||||
response = await step(path="a.md", direction="forward", depth=1)
|
||||
graph = _graph(response)
|
||||
|
||||
assert isinstance(response.answer, str)
|
||||
assert "[0] a.md — Seed: Root note" in response.answer
|
||||
assert "[1] b.md — Branch: First hop" in response.answer
|
||||
assert "[1] c.md — Child: Anchored result" in response.answer
|
||||
assert "a.md -> b.md (depth=1)" in response.answer
|
||||
assert "a.md -> c.md#intro (depth=1)" in response.answer
|
||||
assert graph.model_dump() == response.metadata["graph"]
|
||||
assert graph.seeds == ["a.md"]
|
||||
assert {(edge.source, edge.target, edge.target_anchor) for edge in graph.edges} == {
|
||||
("a.md", "b.md", None),
|
||||
("a.md", "c.md", "intro"),
|
||||
}
|
||||
await store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_traverse_answer_bounds_and_normalizes_descriptions():
|
||||
"""Long multiline frontmatter descriptions stay compact and readable in the answer."""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp, _temp_chdir(tmp):
|
||||
description = "Supported detail.\n" * 20
|
||||
store = await _make_store([_node("a.md", name="Seed", description=description)])
|
||||
step = traverse_mod.TraverseStep(file_store=store)
|
||||
response = await step(path="a.md", direction="forward", depth=1)
|
||||
node_line = response.answer.splitlines()[2]
|
||||
|
||||
assert "\n" not in node_line
|
||||
assert node_line.endswith("...")
|
||||
assert len(node_line.split(": ", maxsplit=1)[1]) == 160
|
||||
await store.close()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue