diff --git a/graphify/llm.py b/graphify/llm.py index 781d30dd43..5a3bd423c7 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -2629,6 +2629,7 @@ def extract_corpus_parallel( "input_tokens": 0, "output_tokens": 0, "failed_chunks": 0, # count of chunks that raised — loud failure on chunk errors } + failed_files: set[Path] = set() total = len(chunks) def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | None]: @@ -2711,6 +2712,7 @@ def _checkpoint_chunk(result: dict, chunk: "list[Path | FileSlice]") -> None: if exc is not None: print(f"[graphify] chunk {idx + 1}/{total} failed: {exc}", file=sys.stderr) merged["failed_chunks"] += 1 + failed_files.update(unit_path(item) for item in chunk) continue assert result is not None _merge_into(merged, result) @@ -2736,6 +2738,7 @@ def _checkpoint_chunk(result: dict, chunk: "list[Path | FileSlice]") -> None: file=sys.stderr, ) merged["failed_chunks"] += 1 + failed_files.update(unit_path(item) for item in chunks[idx]) continue assert result is not None results_by_idx[idx] = result @@ -2838,11 +2841,24 @@ def _out_of_scope(item: dict) -> bool: if p.resolve() not in {c.resolve() for c in covered} ) merged["uncovered_files"] = [str(p) for p in uncovered] - if uncovered: - shown = ", ".join(p.name for p in uncovered[:5]) - more = f" (+{len(uncovered) - 5} more)" if len(uncovered) > 5 else "" + failed_resolved = {_resolve_against_root(p) for p in failed_files} + failed_uncovered = [p for p in uncovered if _resolve_against_root(p) in failed_resolved] + omitted_uncovered = [p for p in uncovered if _resolve_against_root(p) not in failed_resolved] + if failed_uncovered: + shown = ", ".join(p.name for p in failed_uncovered[:5]) + more = f" (+{len(failed_uncovered) - 5} more)" if len(failed_uncovered) > 5 else "" print( - f"[graphify] WARNING: {len(uncovered)}/{len(dispatched)} dispatched file(s) " + f"[graphify] WARNING: {len(failed_uncovered)}/{len(dispatched)} dispatched " + f"file(s) produced no nodes because their semantic chunk failed before " + f"returning a usable result: {shown}{more}. See chunk errors above; a re-run " + "will retry them.", + file=sys.stderr, + ) + if omitted_uncovered: + shown = ", ".join(p.name for p in omitted_uncovered[:5]) + more = f" (+{len(omitted_uncovered) - 5} more)" if len(omitted_uncovered) > 5 else "" + print( + f"[graphify] WARNING: {len(omitted_uncovered)}/{len(dispatched)} dispatched file(s) " f"produced no nodes and are absent from the graph: {shown}{more}. The model " "returned a response but omitted them; a re-run will retry them.", file=sys.stderr, diff --git a/tests/test_chunking.py b/tests/test_chunking.py index dea9f68f26..12899fbe60 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -279,6 +279,34 @@ def maybe_fail(chunk, **kwargs): assert "failed" in err and "simulated API error" in err +def test_failed_chunk_is_not_reported_as_successful_model_omission(tmp_path, capsys): + """A dependency or backend exception means no model response exists. + + The reconciliation warning must not claim the model returned a response and + omitted the file when its chunk failed before producing any result. + """ + from graphify.llm import extract_corpus_parallel + + doc = tmp_path / "guide.md" + doc.write_text("# Guide\n", encoding="utf-8") + + with patch( + "graphify.llm.extract_files_direct", + side_effect=ImportError( + "OpenAI package not installed. Install with: pip install 'graphifyy[gemini]'" + ), + ): + result = extract_corpus_parallel( + [doc], backend="gemini", root=tmp_path, + token_budget=None, chunk_size=1, max_concurrency=1, + ) + + assert result["failed_chunks"] == 1 + err = capsys.readouterr().err + assert "failed before returning a usable result" in err + assert "returned a response but omitted" not in err + + def test_checkpoint_scopes_cache_writes_to_chunk_files(tmp_path): """#1757: the per-chunk incremental checkpoint must not let a chunk's mis-attributed node clobber another corpus file's semantic cache. A chunk @@ -424,6 +452,7 @@ def omit_odd(chunk, **kwargs): assert uncovered == {"doc1.md", "doc3.md"}, f"reconciliation missed omissions: {uncovered}" err = capsys.readouterr().err assert "produced no nodes" in err and "doc1.md" in err + assert "returned a response but omitted" in err def test_out_of_scope_nodes_are_dropped_from_merged_result(tmp_path, capsys):