Skip to content

Commit 940145c

Browse files
committed
fix/wuchengke: optimize document parser image cleanup and agentic retrieval logic
1 parent 1b6cb55 commit 940145c

8 files changed

Lines changed: 140 additions & 140 deletions

File tree

apps/worker/app/services/document_parser/doc_parser.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,21 @@ def iter_block_items(doc_data):
608608
# <v:imagedata> piece-by-piece loses textual overlay and positioning.
609609
# Future plan: Use LibreOffice headless conversion to render the entire document
610610
# and map the perfectly rendered images back to the layout via text anchors.
611+
#
612+
# Temporary: detect VML-only paragraphs and inject a placeholder so the
613+
# paragraph isn't silently swallowed, leaving its parent section empty.
614+
if not text and not seen_rids:
615+
# No text and no DrawingML images — check for VML content
616+
vml_groups = elem.xpath(".//v:group", namespaces=ns)
617+
vml_images_check = elem.xpath(".//v:imagedata", namespaces=ns)
618+
if vml_groups or vml_images_check:
619+
vml_placeholder = "[VML graphic \u2014 extraction not yet supported]"
620+
yield ele_num, vml_placeholder, "PTXT", None
621+
ele_num += 1
622+
logger.debug(
623+
f"Injected VML placeholder for paragraph with "
624+
f"{len(vml_groups)} v:group, {len(vml_images_check)} v:imagedata"
625+
)
611626
"""
612627
# images (VML: <v:imagedata>) — convert to PNG
613628
from PIL import Image as PILImage
@@ -942,6 +957,13 @@ def convert_doc2dics(
942957
for _, row in leaf_dics.iterrows():
943958
key = row["path_identifier"]
944959

960+
# Skip leaf nodes with no actual content (empty heading-only sections)
961+
content_lst = row["content_lst"]
962+
joined = "\n".join(content_lst).strip()
963+
if not joined:
964+
logger.debug(f"Skipping empty leaf node: {key}")
965+
continue
966+
945967
# Build tentative path to check for duplicates
946968
tentative_path = doc_name + split_char + key
947969

@@ -954,7 +976,7 @@ def convert_doc2dics(
954976
path_counter[tentative_path] = 1
955977

956978
path_keys.append((doc_name + split_char + key))
957-
bottom_content = "\n".join(row["content_lst"])
979+
bottom_content = joined
958980
bottom_tokens = tokenize2stw_remove(
959981
[bottom_content], base_llm_paras["stopwords"]
960982
)

apps/worker/app/services/document_parser/md_parser.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,10 @@ def parse_md(
318318
os.makedirs(tb_dir, exist_ok=True)
319319
img_dir = os.path.join(output_dir, "images")
320320
if os.path.isdir(img_dir):
321-
shutil.rmtree(img_dir)
321+
# Only remove parse_md's own output (image-N-*) from previous runs
322+
for fname in os.listdir(img_dir):
323+
if re.match(r"^image-\d+", fname):
324+
os.remove(os.path.join(img_dir, fname))
322325
os.makedirs(img_dir, exist_ok=True)
323326

324327
# initialize vars

packages/shared-python/shared/services/retrieval/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .app_service import list_lexical_chunks, merge_channels_rrf, run_retrieval_query
1+
from .app_service import merge_channels_rrf, run_retrieval_query
22
from .cache_service import (
33
bump_retrieval_namespace_cache_version,
44
get_cached_retrieval_query_result,
@@ -13,7 +13,6 @@
1313
__all__ = [
1414
"create_retrieval_llm_fn",
1515
"run_retrieval_query",
16-
"list_lexical_chunks",
1716
"merge_channels_rrf",
1817
"DocumentGraphService",
1918
"GraphQueryService",

packages/shared-python/shared/services/retrieval/agent_navigate.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,19 @@
5656
User query: {query}
5757
5858
Select the most relevant section paths (at most {max_select}).
59+
If NO section path is relevant to the query, you MUST return an empty array []. Do not force-select irrelevant sections.
5960
Prefer specific sub-items over broad parents when both are listed and the sub-item is sufficient.
6061
61-
For each selected path, choose a hydrate_mode:
62+
For each selected path, assign a confidence score (0.0 to 1.0) where 1.0 means exactly answers the query and 0.5 means tangentially related.
63+
Also choose a hydrate_mode:
6264
- "chunks" (default) return all text/image/table chunks
6365
- "outline" return only section title + summary, no chunk content
6466
- "assets_only" return only image and table chunks
6567
- "image_only" return only image chunks
6668
- "table_only" return only table chunks
6769
6870
Return ONLY a JSON array:
69-
[{{"path": "section/path", "confidence": 0.9, "hydrate_mode": "chunks"}}, ...]
71+
[{{"path": "section/path", "confidence": <float>, "hydrate_mode": "chunks"}}, ...]
7072
Do not include any explanation.
7173
"""
7274

packages/shared-python/shared/services/retrieval/agentic/orchestrator.py

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,27 @@ async def run(
130130
'channel_weights': channel_weights,
131131
}
132132

133-
# ── Agent loop ──
133+
# ── Mandatory pre-step: bottom discovery ─────────────────────────────
134+
# bottom_discovery is always the first action; running it via the LLM
135+
# policy wastes ~1-2s on a trivial LLM call. We execute it directly
136+
# and let the LLM loop start from step 2 (kg_document_select etc.).
137+
logger.info(' agentic: running mandatory bottom_discovery pre-step')
138+
discovery_result = await self._execute_tool(
139+
db, ActionType.BOTTOM_DISCOVERY, state, config, **tool_kwargs,
140+
)
141+
state.apply(ActionType.BOTTOM_DISCOVERY, discovery_result)
142+
if trace_enabled:
143+
trace.record_step(
144+
ActionType.BOTTOM_DISCOVERY, discovery_result,
145+
decision_reason='mandatory_pre_step',
146+
)
147+
state.step_count += 1
148+
logger.info(
149+
f' agentic step {state.step_count} (pre-step): action=bottom_discovery '
150+
f'status={discovery_result.status} latency={discovery_result.latency_ms}ms'
151+
)
152+
153+
# ── Agent loop (LLM decisions start from here) ────────────────────────
134154
stop_reason = 'max_steps'
135155
while state.step_count < config.max_steps:
136156
if state.elapsed_ms >= config.latency_budget_ms:
@@ -143,13 +163,9 @@ async def run(
143163
break
144164

145165
if policy is None:
146-
# No LLM: run discovery once then stop
147-
if not state.discovery_done:
148-
action_type = ActionType.BOTTOM_DISCOVERY
149-
decision_reason = 'no_llm_fn: discovery only'
150-
else:
151-
stop_reason = 'no_llm_fn'
152-
break
166+
# No LLM: discovery already ran — stop
167+
stop_reason = 'no_llm_fn'
168+
break
153169
else:
154170
action_type, decision_reason = await policy.decide(state, config)
155171

@@ -244,6 +260,26 @@ async def run(
244260
state.kg_done = False
245261
state.discovery_done = False
246262

263+
# Mandatory bottom_discovery pre-step for revision round
264+
logger.info(
265+
f' agentic: running mandatory bottom_discovery pre-step '
266+
f'(revision {state.revision_count})'
267+
)
268+
rev_discovery = await self._execute_tool(
269+
db, ActionType.BOTTOM_DISCOVERY, state, config, **tool_kwargs,
270+
)
271+
state.apply(ActionType.BOTTOM_DISCOVERY, rev_discovery)
272+
if trace_enabled:
273+
trace.record_step(
274+
ActionType.BOTTOM_DISCOVERY, rev_discovery,
275+
decision_reason=f'mandatory_pre_step (revision {state.revision_count})',
276+
)
277+
state.step_count += 1
278+
logger.info(
279+
f' agentic step {state.step_count} (rev {state.revision_count} pre-step): '
280+
f'action=bottom_discovery status={rev_discovery.status}'
281+
)
282+
247283
# Re-enter agent loop
248284
while state.step_count < config.max_steps:
249285
if state.elapsed_ms >= config.latency_budget_ms:

packages/shared-python/shared/services/retrieval/agentic/policy.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,11 @@
2323

2424
# ── Available actions presented to the LLM ───────────────────────────────────
2525

26+
# NOTE: BOTTOM_DISCOVERY is intentionally excluded from this list.
27+
# It is now a mandatory pre-step executed automatically by the orchestrator
28+
# before the LLM decision loop begins. The LLM should never need to decide
29+
# whether to run it — doing so wastes one LLM call per run.
2630
_AVAILABLE_ACTIONS: list[dict[str, Any]] = [
27-
{
28-
'action': ActionType.BOTTOM_DISCOVERY.value,
29-
'description': (
30-
'Run BM25 3-channel bottom-layer discovery (path / content / term). '
31-
'Always call this first to get candidate chunks and top document hints.'
32-
),
33-
'when': 'discovery_done is false',
34-
},
3531
{
3632
'action': ActionType.KG_DOCUMENT_SELECT.value,
3733
'description': (
@@ -94,12 +90,12 @@
9490
{actions_block}
9591
9692
RULES:
97-
1. Always run bottom_discovery first (if discovery_done is false).
98-
2. After discovery, run kg_document_select (if kg_done is false).
99-
3. After kg select, run document_path_select for each pending document.
100-
4. Call done when all pending documents are processed OR you have >= {min_evidence} evidence paths.
101-
5. Only use grep_document_discover if kg_document_select found 0 documents.
102-
6. Only use graph_expand_docs if you need more related docs after reviewing results.
93+
1. Run kg_document_select when discovery_done is true and kg_done is false.
94+
2. After kg select, run document_path_select for each pending document.
95+
3. Call done when all pending documents are processed OR you have >= {min_evidence} evidence paths.
96+
4. Only use grep_document_discover if kg_document_select found 0 documents.
97+
5. Only use graph_expand_docs if you need more related docs after reviewing results.
98+
Note: bottom_discovery is already executed automatically before this loop — do NOT attempt to call it.
10399
104100
Return ONLY a JSON object, no markdown, no explanation:
105101
{{"action": "<action_name>", "reason": "<one sentence why>"}}
@@ -145,9 +141,26 @@ def __init__(self, llm_fn: LLMFn, *, query: str = '') -> None:
145141
def build_prompt(self, state: AgentState, config: AgentRunConfig) -> str:
146142
"""Build the decision prompt. Public for test inspection."""
147143
state_data = state.state_summary()
144+
145+
has_pending_docs = state.pending_doc_index < len(state.selected_docs)
146+
state_data['has_pending_docs'] = has_pending_docs
148147
state_json = json.dumps(state_data, ensure_ascii=False, indent=2)
149148

150-
# Count pending docs
149+
allowed_actions = []
150+
for action in _AVAILABLE_ACTIONS:
151+
name = action['action']
152+
if name == ActionType.KG_DOCUMENT_SELECT.value and (not state.discovery_done or state.kg_done):
153+
continue
154+
if name == ActionType.DOCUMENT_PATH_SELECT.value and (not state.kg_done or not has_pending_docs):
155+
continue
156+
if name == ActionType.GREP_DOCUMENT_DISCOVER.value and (not state.kg_done or len(state.selected_docs) > 0):
157+
continue
158+
allowed_actions.append(action)
159+
160+
actions_block = '\n'.join(
161+
f" {i+1}. \"{a['action']}\": {a['description']} [{a['when']}]"
162+
for i, a in enumerate(allowed_actions)
163+
)
151164

152165
return _POLICY_PROMPT_TEMPLATE.format(
153166
query=self._query,
@@ -156,7 +169,7 @@ def build_prompt(self, state: AgentState, config: AgentRunConfig) -> str:
156169
budget_ms=config.latency_budget_ms,
157170
step=state.step_count,
158171
max_steps=config.max_steps,
159-
actions_block=_ACTIONS_BLOCK,
172+
actions_block=actions_block,
160173
min_evidence=config.min_evidence_paths,
161174
)
162175

packages/shared-python/shared/services/retrieval/app_service.py

Lines changed: 8 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -251,89 +251,7 @@ async def assemble_retrieval_results(
251251
return assembled
252252

253253

254-
async def list_lexical_chunks(
255-
db: AsyncSession,
256-
*,
257-
user_id: str,
258-
namespace: str,
259-
query: str,
260-
top_k: int,
261-
exclude_document_ids: list[str],
262-
exclude_sections: list[dict[str, str]],
263-
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
264-
"""Independent lexical retrieval: path + content channels via ILIKE."""
265-
recall_k = top_k * _INTERNAL_RECALL_K_MULTIPLIER
266-
excluded_docs = set(exclude_document_ids)
267-
268-
base_stmt = (
269-
select(Document, DocumentChunk, DocumentSection, JobResult)
270-
.join(DocumentChunk, (DocumentChunk.document_id == Document.document_id) & (DocumentChunk.job_result_id == Document.current_job_result_id))
271-
.outerjoin(DocumentSection, DocumentSection.section_id == DocumentChunk.section_id)
272-
.join(JobResult, JobResult.id == DocumentChunk.job_result_id)
273-
.where(Document.user_id == user_id)
274-
.where(Document.namespace == namespace)
275-
.where(Document.status == 'active')
276-
)
277-
if excluded_docs:
278-
base_stmt = base_stmt.where(Document.document_id.notin_(list(excluded_docs)))
279-
280-
like = f'%{query}%'
281-
content_stmt = base_stmt.where(DocumentChunk.content_lexical_text.ilike(like)).order_by(DocumentChunk.sort_order).limit(recall_k)
282-
path_stmt = base_stmt.where(DocumentChunk.path_lexical_text.ilike(like)).order_by(DocumentChunk.sort_order).limit(recall_k)
283-
284-
# AsyncSession is stateful and should not be shared across concurrent tasks.
285-
content_result = await db.execute(content_stmt)
286-
path_result = await db.execute(path_stmt)
287-
288-
def _to_rows(result, channel_score: float) -> list[dict[str, Any]]:
289-
rows: list[dict[str, Any]] = []
290-
for document, chunk, section, job_result in result.all():
291-
section_path = section.section_path if section else None
292-
if is_excluded_section(document_id=document.document_id, section_path=section_path, exclude_sections=exclude_sections):
293-
continue
294-
rows.append({
295-
'document_id': document.document_id,
296-
'chunk_id': chunk.chunk_id,
297-
'section_id': chunk.section_id,
298-
'section_path': section_path,
299-
'source_file_name': document.source_file_name,
300-
'chunk_type': chunk.chunk_type,
301-
'content': chunk.content,
302-
'score': channel_score,
303-
'file_path': chunk.file_path,
304-
'chunk_metadata': chunk.chunk_metadata or {},
305-
'job_result_id': chunk.job_result_id,
306-
'job_id': job_result.job_id if job_result else None,
307-
})
308-
return rows
309-
310-
content_rows = _to_rows(content_result, _CHANNEL_WEIGHT_CONTENT)
311-
path_rows = _to_rows(path_result, _CHANNEL_WEIGHT_PATH)
312-
return content_rows, path_rows
313-
314254

315-
def _grep_search_rows(rows: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
316-
"""Term/grep channel: exact substring matching with scoring from knowhere-kb."""
317-
import re
318-
query_lower = query.lower().strip()
319-
if not query_lower:
320-
return []
321-
322-
units = re.findall(r'[一-鿿]+|[a-zA-Z0-9]+', query_lower)
323-
units = [u for u in units if len(u) > 1]
324-
325-
scored: list[tuple[float, dict[str, Any]]] = []
326-
for row in rows:
327-
haystack = (str(row.get('content') or '') + ' ' + str(row.get('section_path') or '')).lower()
328-
if query_lower in haystack:
329-
scored.append((100.0, row))
330-
elif units:
331-
hit_count = sum(1 for u in units if u in haystack)
332-
if hit_count > 0:
333-
scored.append((float(hit_count), row))
334-
335-
scored.sort(key=lambda x: x[0], reverse=True)
336-
return [dict(row, score=score) for score, row in scored]
337255

338256

339257
def _merge_same_section_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -690,13 +608,14 @@ def _rank_candidates_by_path(
690608
else:
691609
primary_rows.append(row)
692610

693-
_sort_key = lambda row: (
694-
float(row.get('agent_score', 0.0) or 0.0),
695-
float(row.get('discovery_score', 0.0) or 0.0),
696-
int(row.get('dual_hit_flag', 0) or 0),
697-
float(row.get('importance_norm_score', 0.0) or 0.0),
698-
-int(row.get('_candidate_order', 0) or 0),
699-
)
611+
def _sort_key(row):
612+
return (
613+
float(row.get('agent_score', 0.0) or 0.0),
614+
float(row.get('discovery_score', 0.0) or 0.0),
615+
int(row.get('dual_hit_flag', 0) or 0),
616+
float(row.get('importance_norm_score', 0.0) or 0.0),
617+
-int(row.get('_candidate_order', 0) or 0),
618+
)
700619

701620
primary_rows.sort(key=_sort_key, reverse=True)
702621
ranked_rows = primary_rows[:top_k]

0 commit comments

Comments
 (0)