Skip to content

Commit fdcfa0e

Browse files
authored
Generate resource L0 summaries from overview prompt (#2890)
1 parent e1fdaf4 commit fdcfa0e

8 files changed

Lines changed: 234 additions & 56 deletions

openviking/storage/queuefs/semantic_dag.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -812,8 +812,7 @@ async def _overview_task(self, dir_uri: str) -> None:
812812
overview = await self._processor._generate_overview(
813813
dir_uri, file_summaries, children_abstracts
814814
)
815-
abstract = self._processor._extract_abstract_from_overview(overview)
816-
overview, abstract = self._processor._enforce_size_limits(overview, abstract)
815+
overview, abstract = self._processor._normalize_overview_generation(overview)
817816

818817
# Write directly, protected by the outer semantic lock.
819818
try:

openviking/storage/queuefs/semantic_processor.py

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""SemanticProcessor: Processes messages from SemanticQueue, generates .abstract.md and .overview.md."""
44

55
import asyncio
6+
import re
67
import threading
78
from contextlib import nullcontext
89
from dataclasses import dataclass, field
@@ -652,11 +653,10 @@ async def _gen(idx: int, file_path: str) -> None:
652653
for file_path, summary in zip(file_paths, file_summaries, strict=False)
653654
if file_path in paths_to_vectorize and summary is not None
654655
]
655-
overview = await self._generate_overview(
656+
generated_content = await self._generate_overview(
656657
dir_uri, completed_summaries, [], llm_sem=llm_sem
657658
)
658-
abstract = self._extract_abstract_from_overview(overview)
659-
overview, abstract = self._enforce_size_limits(overview, abstract)
659+
overview, abstract = self._normalize_overview_generation(generated_content)
660660

661661
try:
662662
wrote_semantics = await self._write_memory_directory_semantics(
@@ -1155,8 +1155,49 @@ async def _generate_single_file_summary(
11551155
else:
11561156
return await self._generate_text_summary(file_path, file_name, llm_sem, ctx=ctx)
11571157

1158+
def _replace_index_references(
1159+
self, generated_content: str, file_index_map: Dict[int, str]
1160+
) -> str:
1161+
def replace_index(match):
1162+
idx = int(match.group(1))
1163+
return file_index_map.get(idx, match.group(0))
1164+
1165+
return re.sub(r"\[(\d+)\]", replace_index, generated_content)
1166+
1167+
def _truncate_generated_text(self, text: str, max_chars: int) -> str:
1168+
if max_chars <= 0 or len(text) <= max_chars:
1169+
return text
1170+
1171+
if max_chars <= 3:
1172+
return text[:max_chars]
1173+
1174+
first_sentence_end = None
1175+
last_sentence_end_within_limit = None
1176+
for sentence_end_match in re.finditer(
1177+
r"\.(?!\d)(?=\s|$)|[!?](?=\s|$)|[。?!]", text
1178+
):
1179+
sentence_end = sentence_end_match.end()
1180+
if first_sentence_end is None:
1181+
first_sentence_end = sentence_end
1182+
if sentence_end <= max_chars:
1183+
last_sentence_end_within_limit = sentence_end
1184+
elif last_sentence_end_within_limit is not None:
1185+
break
1186+
1187+
if last_sentence_end_within_limit is not None:
1188+
return text[:last_sentence_end_within_limit].strip()
1189+
if first_sentence_end is not None:
1190+
return text[:first_sentence_end].strip()
1191+
1192+
candidate = text[: max_chars - 3].rstrip()
1193+
word_boundary = candidate.rfind(" ")
1194+
if word_boundary > 0:
1195+
return candidate[:word_boundary].rstrip() + "..."
1196+
1197+
return candidate + "..."
1198+
11581199
def _extract_abstract_from_overview(self, overview_content: str) -> str:
1159-
"""Extract abstract from overview.md."""
1200+
"""Extract an abstract from the Markdown overview brief description."""
11601201
lines = overview_content.split("\n")
11611202

11621203
# Skip header lines (starting with #)
@@ -1178,13 +1219,19 @@ def _extract_abstract_from_overview(self, overview_content: str) -> str:
11781219

11791220
return "\n".join(content_lines).strip()
11801221

1222+
def _normalize_overview_generation(self, generated_content: str) -> Tuple[str, str]:
1223+
"""Convert raw Markdown overview output into final L1 overview and L0 abstract."""
1224+
overview = generated_content
1225+
abstract = self._extract_abstract_from_overview(generated_content)
1226+
return self._enforce_size_limits(overview, abstract)
1227+
11811228
def _enforce_size_limits(self, overview: str, abstract: str) -> Tuple[str, str]:
11821229
"""Enforce max size limits on overview and abstract."""
11831230
semantic = get_openviking_config().semantic
11841231
if len(overview) > semantic.overview_max_chars:
1185-
overview = overview[: semantic.overview_max_chars]
1232+
overview = self._truncate_generated_text(overview, semantic.overview_max_chars)
11861233
if len(abstract) > semantic.abstract_max_chars:
1187-
abstract = abstract[: semantic.abstract_max_chars - 3] + "..."
1234+
abstract = self._truncate_generated_text(abstract, semantic.abstract_max_chars)
11881235
return overview, abstract
11891236

11901237
def _parse_overview_md(self, overview_content: str) -> Dict[str, str]:
@@ -1247,7 +1294,7 @@ async def _generate_overview(
12471294
children_abstracts: List[Dict[str, str]],
12481295
llm_sem: Optional[asyncio.Semaphore] = None,
12491296
) -> str:
1250-
"""Generate directory's .overview.md (L1).
1297+
"""Generate raw directory overview model output.
12511298
12521299
For small directories, generates a single overview from all file summaries.
12531300
For large directories that would exceed the prompt budget, splits file
@@ -1260,7 +1307,7 @@ async def _generate_overview(
12601307
children_abstracts: Subdirectory summary list
12611308
12621309
Returns:
1263-
Overview content
1310+
Markdown overview content generated by the model.
12641311
"""
12651312

12661313
config = get_openviking_config()
@@ -1359,9 +1406,8 @@ async def _single_generate_overview(
13591406
output_language: str = "en",
13601407
) -> str:
13611408
"""Generate overview from a single prompt (small directories)."""
1362-
import re
1363-
1364-
vlm = get_openviking_config().vlm
1409+
config = get_openviking_config()
1410+
vlm = config.vlm
13651411

13661412
try:
13671413
prompt = render_prompt(
@@ -1377,12 +1423,7 @@ async def _single_generate_overview(
13771423
with bind_telemetry_stage("resource_summarize"):
13781424
overview = await vlm.get_completion_async(prompt)
13791425

1380-
# Post-process: replace [number] with actual file name
1381-
def replace_index(match):
1382-
idx = int(match.group(1))
1383-
return file_index_map.get(idx, match.group(0))
1384-
1385-
overview = re.sub(r"\[(\d+)\]", replace_index, overview)
1426+
overview = self._replace_index_references(overview, file_index_map)
13861427

13871428
return overview.strip()
13881429

@@ -1407,10 +1448,9 @@ async def _batched_generate_overview(
14071448
Splits file summaries into batches, generates a partial overview per
14081449
batch, then merges all partials into a final overview.
14091450
"""
1410-
import re
1411-
1412-
vlm = get_openviking_config().vlm
1413-
semantic = get_openviking_config().semantic
1451+
config = get_openviking_config()
1452+
vlm = config.vlm
1453+
semantic = config.semantic
14141454
batch_size = semantic.overview_batch_size
14151455
dir_name = dir_uri.split("/")[-1]
14161456

@@ -1459,19 +1499,12 @@ async def _batched_generate_overview(
14591499
)
14601500
batch_prompts.append((batch_idx, prompt, batch_index_map))
14611501

1462-
def make_replacer(idx_map):
1463-
def replacer(match):
1464-
idx = int(match.group(1))
1465-
return idx_map.get(idx, match.group(0))
1466-
1467-
return replacer
1468-
14691502
async def _run_batch(batch_idx: int, prompt: str, batch_index_map: Dict[int, str]) -> None:
14701503
try:
14711504
async with llm_sem:
14721505
with bind_telemetry_stage("resource_summarize"):
14731506
partial = await vlm.get_completion_async(prompt)
1474-
partial = re.sub(r"\[(\d+)\]", make_replacer(batch_index_map), partial)
1507+
partial = self._replace_index_references(partial, batch_index_map)
14751508
partial_overviews[batch_idx] = partial.strip()
14761509
except Exception as e:
14771510
logger.warning(
@@ -1503,6 +1536,7 @@ async def _run_batch(batch_idx: int, prompt: str, batch_index_map: Dict[int, str
15031536
)
15041537
with bind_telemetry_stage("resource_summarize"):
15051538
overview = await vlm.get_completion_async(prompt)
1539+
overview = self._replace_index_references(overview, file_index_map)
15061540
return overview.strip()
15071541
except Exception as e:
15081542
logger.error(

tests/storage/test_semantic_dag_incremental.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,8 @@ async def _generate_overview(self, dir_uri, file_summaries, children_abstracts):
8686
lines.append(f"- {name}: {summary}")
8787
return "\n".join(lines)
8888

89-
def _extract_abstract_from_overview(self, overview):
90-
return "abstract"
91-
92-
def _enforce_size_limits(self, overview, abstract):
93-
return overview, abstract
89+
def _normalize_overview_generation(self, overview):
90+
return overview, "abstract"
9491

9592
async def _sync_topdown_recursive(
9693
self, root_uri, target_uri, ctx=None, file_change_status=None, lock=None

tests/storage/test_semantic_dag_skip_files.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,8 @@ async def _generate_single_file_summary(self, file_path, llm_sem=None, ctx=None)
5454
async def _generate_overview(self, dir_uri, file_summaries, children_abstracts):
5555
return "overview"
5656

57-
def _extract_abstract_from_overview(self, overview):
58-
return "abstract"
59-
60-
def _enforce_size_limits(self, overview, abstract):
61-
return overview, abstract
57+
def _normalize_overview_generation(self, overview):
58+
return overview, "abstract"
6259

6360
async def _vectorize_directory(
6461
self, uri, context_type, abstract, overview, ctx=None, semantic_msg_id=None

tests/storage/test_semantic_dag_stats.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ def __init__(self, tree):
1616
self._tree = tree
1717
self.writes = []
1818

19-
async def ls(self, uri, ctx=None):
19+
async def ls(self, uri, node_limit=None, ctx=None):
20+
del node_limit
2021
return self._tree.get(uri, [])
2122

2223
async def write_file(self, path, content, ctx=None):
@@ -37,11 +38,8 @@ async def _generate_single_file_summary(self, file_path, llm_sem=None, ctx=None)
3738
async def _generate_overview(self, dir_uri, file_summaries, children_abstracts):
3839
return "overview"
3940

40-
def _extract_abstract_from_overview(self, overview):
41-
return "abstract"
42-
43-
def _enforce_size_limits(self, overview, abstract):
44-
return overview, abstract
41+
def _normalize_overview_generation(self, overview):
42+
return overview, "abstract"
4543

4644
async def _vectorize_directory(
4745
self, uri, context_type, abstract, overview, ctx=None, semantic_msg_id=None
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
2+
# SPDX-License-Identifier: AGPL-3.0
3+
4+
from types import SimpleNamespace
5+
6+
from openviking.storage.queuefs import semantic_processor as semantic_processor_module
7+
from openviking.storage.queuefs.semantic_processor import SemanticProcessor
8+
9+
10+
def _patch_semantic_limits(monkeypatch, *, abstract_max_chars=256, overview_max_chars=4000):
11+
config = SimpleNamespace(
12+
semantic=SimpleNamespace(
13+
abstract_max_chars=abstract_max_chars,
14+
overview_max_chars=overview_max_chars,
15+
)
16+
)
17+
monkeypatch.setattr(semantic_processor_module, "get_openviking_config", lambda: config)
18+
19+
20+
def test_markdown_overview_uses_brief_description_as_abstract(monkeypatch):
21+
_patch_semantic_limits(monkeypatch)
22+
processor = SemanticProcessor()
23+
generated = (
24+
"# README\n\n"
25+
"This brief description is the retrieval abstract.\n\n"
26+
"## Quick Navigation\n\n"
27+
"- Read README.md"
28+
)
29+
30+
overview, abstract = processor._normalize_overview_generation(generated)
31+
32+
assert overview == generated
33+
assert abstract == "This brief description is the retrieval abstract."
34+
35+
36+
def test_markdown_overview_extracts_multiline_brief_description(monkeypatch):
37+
_patch_semantic_limits(monkeypatch)
38+
processor = SemanticProcessor()
39+
generated = (
40+
"# README\n\n"
41+
"This is the first abstract line.\n"
42+
"This is the second abstract line.\n\n"
43+
"## Quick Navigation\n\n"
44+
"- Read README.md"
45+
)
46+
47+
overview, abstract = processor._normalize_overview_generation(generated)
48+
49+
assert overview == generated
50+
assert abstract == "This is the first abstract line.\nThis is the second abstract line."
51+
52+
53+
def test_index_references_are_replaced_inside_markdown_overview(monkeypatch):
54+
_patch_semantic_limits(monkeypatch)
55+
processor = SemanticProcessor()
56+
generated = "# README\n\nUse [1] to get started."
57+
58+
replaced = processor._replace_index_references(generated, {1: "README.md"})
59+
60+
assert replaced == "# README\n\nUse README.md to get started."
61+
62+
63+
def test_abstract_truncation_prefers_complete_sentence(monkeypatch):
64+
_patch_semantic_limits(monkeypatch, abstract_max_chars=80)
65+
processor = SemanticProcessor()
66+
abstract = (
67+
"This is a complete sentence. "
68+
"This second sentence contains onboarding material that would be cut."
69+
)
70+
71+
overview, abstract = processor._enforce_size_limits("# README\n\nBody", abstract)
72+
73+
assert overview == "# README\n\nBody"
74+
assert abstract == "This is a complete sentence."
75+
76+
77+
def test_abstract_truncation_keeps_first_sentence_even_over_limit(monkeypatch):
78+
_patch_semantic_limits(monkeypatch, abstract_max_chars=80)
79+
processor = SemanticProcessor()
80+
first_sentence = (
81+
"This directory is a timestamped media storage container for a single MP4 video "
82+
"file, organized to preserve the exact capture or creation time of its contents."
83+
)
84+
abstract = f"{first_sentence} This second sentence should be omitted."
85+
86+
_, abstract = processor._enforce_size_limits("# video\n\nBody", abstract)
87+
88+
assert abstract == first_sentence
89+
90+
91+
def test_overview_truncation_prefers_complete_sentence(monkeypatch):
92+
_patch_semantic_limits(monkeypatch, overview_max_chars=45)
93+
processor = SemanticProcessor()
94+
overview = (
95+
"# README\n\n"
96+
"This is a complete sentence. "
97+
"This second sentence would be cut in the middle."
98+
)
99+
100+
overview, abstract = processor._enforce_size_limits(overview, "abstract")
101+
102+
assert overview == "# README\n\nThis is a complete sentence."
103+
assert abstract == "abstract"
104+
105+
106+
def test_overview_truncation_keeps_last_complete_sentence_within_limit(monkeypatch):
107+
_patch_semantic_limits(monkeypatch, overview_max_chars=57)
108+
processor = SemanticProcessor()
109+
overview = (
110+
"# README\n\n"
111+
"First sentence. "
112+
"Second sentence. "
113+
"Third sentence should be omitted."
114+
)
115+
116+
overview, abstract = processor._enforce_size_limits(overview, "abstract")
117+
118+
assert overview == "# README\n\nFirst sentence. Second sentence."
119+
assert abstract == "abstract"
120+
121+
122+
def test_truncation_keeps_multiple_short_sentences_within_limit(monkeypatch):
123+
_patch_semantic_limits(monkeypatch, abstract_max_chars=10)
124+
processor = SemanticProcessor()
125+
126+
_, abstract = processor._enforce_size_limits("# README\n\nBody", "A. B. C. D.E.")
127+
128+
assert abstract == "A. B. C."
129+
130+
131+
def test_abstract_truncation_does_not_treat_decimal_point_as_sentence_end_without_period(
132+
monkeypatch,
133+
):
134+
_patch_semantic_limits(monkeypatch, abstract_max_chars=24)
135+
processor = SemanticProcessor()
136+
abstract = "This covers version 3.14 compatibility checks for onboarding"
137+
138+
_, abstract = processor._enforce_size_limits("# README\n\nBody", abstract)
139+
140+
assert abstract == "This covers version..."
141+
142+
143+
def test_abstract_truncation_accepts_sentence_period_after_number(monkeypatch):
144+
_patch_semantic_limits(monkeypatch, abstract_max_chars=70)
145+
processor = SemanticProcessor()
146+
abstract = (
147+
"This import check was generated at 16:55. "
148+
"This second sentence would otherwise be truncated midstream."
149+
)
150+
151+
_, abstract = processor._enforce_size_limits("# README\n\nBody", abstract)
152+
153+
assert abstract == "This import check was generated at 16:55."

0 commit comments

Comments
 (0)