mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-15 21:11:05 +02:00
fix: resolve five P1 defects from the SDK review
- AgentRunner.run: offload to a worker-thread event loop when called from inside a running loop (Jupyter, FastAPI handlers) — mirrors pipeline._run_async; Runner.run_sync raised RuntimeError there. - SQLiteStorage: create connections with check_same_thread=False so close() can actually close connections created by worker threads. Each thread still gets its own connection via threading.local; with the default True those closes raised ProgrammingError (silently swallowed) and leaked every worker connection. - CloudBackend.query: non-streaming chat completions now use a 300s timeout and a single attempt. The default 30s ReadTimeout fired before generation finished and the retry loop re-billed the full server-side retrieval + generation up to three times. _request gains retries/timeout overrides; the exhausted-retry path also no longer sleeps before raising. - MarkdownParser: content before the first heading (abstract/preamble) becomes a node instead of being silently dropped and unretrievable; a file with no headings at all yields a single document node instead of zero nodes (which pushed an empty page list into the pipeline). - LegacyCloudAPI.is_retrieval_ready: API failures (revoked key, network down) now propagate as PageIndexAPIError instead of reading as "not ready", which turned polling loops into infinite loops. Adds regression tests for each fix. Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
This commit is contained in:
parent
6a73279c0c
commit
956147d864
10 changed files with 171 additions and 16 deletions
|
|
@ -18,3 +18,30 @@ def test_scoped_prompt_omits_list_documents():
|
|||
assert "list_documents" not in SCOPED_SYSTEM_PROMPT
|
||||
assert "get_document_structure" in SCOPED_SYSTEM_PROMPT
|
||||
assert "get_page_content" in SCOPED_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_run_works_inside_running_event_loop(monkeypatch):
|
||||
"""Regression: Runner.run_sync raises RuntimeError under a running loop
|
||||
(Jupyter/FastAPI); AgentRunner.run must offload to a worker thread."""
|
||||
import asyncio
|
||||
agents = __import__("agents")
|
||||
|
||||
class FakeResult:
|
||||
final_output = "ok"
|
||||
|
||||
async def fake_run(agent, question):
|
||||
return FakeResult()
|
||||
|
||||
def fail_run_sync(agent, question):
|
||||
raise AssertionError("run_sync must not be called inside a running loop")
|
||||
|
||||
monkeypatch.setattr(agents.Runner, "run", fake_run)
|
||||
monkeypatch.setattr(agents.Runner, "run_sync", fail_run_sync)
|
||||
monkeypatch.setattr(agents, "Agent", lambda **kwargs: object())
|
||||
|
||||
runner = AgentRunner(tools=AgentTools(function_tools=[]), model="gpt-4o")
|
||||
|
||||
async def main():
|
||||
return runner.run("question") # sync call from inside a running loop
|
||||
|
||||
assert asyncio.run(main()) == "ok"
|
||||
|
|
|
|||
|
|
@ -202,3 +202,20 @@ def test_query_stream_connect_failure_raises_instead_of_hanging(monkeypatch):
|
|||
|
||||
with pytest.raises(CloudAPIError, match="request failed"):
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_query_uses_long_timeout_and_single_attempt(monkeypatch):
|
||||
"""Non-streaming chat completion is non-idempotent and slow: it must get
|
||||
a long timeout and must NOT be retried (each retry re-bills the query)."""
|
||||
backend = CloudBackend(api_key="pi-test")
|
||||
calls = []
|
||||
|
||||
def fake_request(method, url, headers=None, **kwargs):
|
||||
calls.append(kwargs)
|
||||
raise cloud_mod.requests.ConnectionError("boom")
|
||||
|
||||
monkeypatch.setattr(cloud_mod.requests, "request", fake_request)
|
||||
with pytest.raises(CloudAPIError):
|
||||
backend.query("col", "q", doc_ids=["d1"])
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["timeout"] == 300
|
||||
|
|
|
|||
|
|
@ -324,3 +324,14 @@ def test_empty_api_key_warns_and_falls_back_to_local(caplog, tmp_path, monkeypat
|
|||
|
||||
assert any("empty api_key" in r.message for r in caplog.records)
|
||||
assert client._legacy_cloud_api is None
|
||||
|
||||
|
||||
def test_is_retrieval_ready_propagates_api_errors(monkeypatch):
|
||||
"""Regression: API failures (revoked key etc.) were swallowed as False,
|
||||
turning `while not is_retrieval_ready(...)` into an infinite poll."""
|
||||
def fake_request(method, url, **kwargs):
|
||||
return FakeResponse(status_code=401, text="invalid api key")
|
||||
|
||||
monkeypatch.setattr("pageindex.cloud_api.requests.request", fake_request)
|
||||
with pytest.raises(PageIndexAPIError):
|
||||
PageIndexClient("pi-test").is_retrieval_ready("doc-1")
|
||||
|
|
|
|||
|
|
@ -53,3 +53,21 @@ def test_parse_nodes_have_index(sample_md):
|
|||
result = parser.parse(sample_md)
|
||||
for node in result.nodes:
|
||||
assert node.index is not None
|
||||
|
||||
|
||||
def test_preamble_before_first_header_is_kept(tmp_path):
|
||||
md = tmp_path / "pre.md"
|
||||
md.write_text("Abstract: important preamble text.\n\n# Chapter 1\nBody.\n")
|
||||
result = MarkdownParser().parse(str(md))
|
||||
assert result.nodes[0].title == "pre"
|
||||
assert "important preamble text" in result.nodes[0].content
|
||||
assert result.nodes[1].title == "Chapter 1"
|
||||
|
||||
|
||||
def test_headerless_file_yields_single_node(tmp_path):
|
||||
md = tmp_path / "plain.md"
|
||||
md.write_text("Just some text.\nNo headings at all.\n")
|
||||
result = MarkdownParser().parse(str(md))
|
||||
assert len(result.nodes) == 1
|
||||
assert result.nodes[0].title == "plain"
|
||||
assert "No headings at all" in result.nodes[0].content
|
||||
|
|
|
|||
|
|
@ -59,3 +59,23 @@ def test_delete_collection_cascades_documents(storage):
|
|||
storage.save_document("papers", "doc-1", {"doc_name": "test.pdf", "doc_type": "pdf", "file_path": "/tmp/test.pdf", "structure": []})
|
||||
storage.delete_collection("papers")
|
||||
assert "papers" not in storage.list_collections()
|
||||
|
||||
|
||||
def test_close_closes_connections_created_in_other_threads(storage):
|
||||
"""Regression: with check_same_thread=True, close() from another thread
|
||||
raised ProgrammingError (swallowed) and leaked every worker connection."""
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
conns = {}
|
||||
|
||||
def worker():
|
||||
conns["worker"] = storage._get_conn()
|
||||
|
||||
t = threading.Thread(target=worker)
|
||||
t.start()
|
||||
t.join()
|
||||
|
||||
storage.close() # main thread closes the worker's connection too
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
conns["worker"].execute("SELECT 1")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue