fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""Tests for ``application.worker.convert_source_to_wiki_worker``.
|
|
|
|
The worker reassembles wiki pages from a source's existing vector-store
|
|
chunks (grouped by ``metadata.source``) and enqueues a per-page re-embed.
|
|
``VectorCreator.create_vectorstore`` and the re-embed task are mocked so no
|
|
real store access or embeddings run; the ``sources`` / ``wiki_pages`` rows
|
|
are real so the assertions read them back.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from application.storage.db.repositories.sources import SourcesRepository
|
|
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
|
|
|
|
|
|
def _seed_source(pg_conn, config=None, file_path=""):
|
|
src = SourcesRepository(pg_conn).create(
|
|
"doc-set",
|
|
user_id="alice",
|
|
type="crawler",
|
|
retriever="classic",
|
|
file_path=file_path,
|
|
config=config or {},
|
|
directory_structure={"a.md": {"type": "text/markdown", "size_bytes": 1}},
|
|
)
|
|
return str(src["id"])
|
|
|
|
|
|
def _chunk(text, source=None, doc_id="d", **extra_meta):
|
|
metadata = dict(extra_meta)
|
|
if source is not None:
|
|
metadata["source"] = source
|
|
return {"doc_id": doc_id, "text": text, "metadata": metadata}
|
|
|
|
|
|
def _patch_store(monkeypatch, chunks):
|
|
store = MagicMock(name="vectorstore")
|
|
store.get_chunks.return_value = chunks
|
|
monkeypatch.setattr(
|
|
"application.vectorstore.vector_creator.VectorCreator.create_vectorstore",
|
|
lambda *a, **kw: store,
|
|
)
|
|
return store
|
|
|
|
|
|
def _patch_reembed(monkeypatch):
|
|
delay = MagicMock(name="reembed_delay")
|
|
monkeypatch.setattr("application.api.user.tasks.reembed_wiki_page.delay", delay)
|
|
return delay
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestConvertSourceToWikiWorker:
|
|
def test_two_pages_reassembled_from_chunks(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("guide part one.", source="guide.md", doc_id="g1"),
|
|
_chunk("guide part two.", source="guide.md", doc_id="g2"),
|
|
_chunk("intro body.", source="notes/intro.md", doc_id="i1"),
|
|
],
|
|
)
|
|
delay = _patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "converted"
|
|
assert result["pages_created"] == 2
|
|
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
by_path = {p["path"]: p for p in pages}
|
|
assert set(by_path) == {"/guide.md", "/notes/intro.md"}
|
|
assert by_path["/guide.md"]["content"] == "guide part one.\n\nguide part two."
|
|
assert by_path["/guide.md"]["title"] == "guide.md"
|
|
assert by_path["/notes/intro.md"]["content"] == "intro body."
|
|
assert by_path["/notes/intro.md"]["title"] == "intro.md"
|
|
|
|
assert delay.call_count == 2
|
|
paths = {c.args[1] for c in delay.call_args_list}
|
|
assert paths == {"/guide.md", "/notes/intro.md"}
|
|
for call in delay.call_args_list:
|
|
assert call.args[0] == source_id
|
|
assert call.kwargs["user"] == "alice"
|
|
assert call.kwargs["idempotency_key"].startswith(
|
|
f"reembed-wiki:{source_id}:"
|
|
)
|
|
|
|
def test_original_chunks_deleted_after_convert(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
store = _patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("guide part one.", source="guide.md", doc_id="g1"),
|
|
_chunk("guide part two.", source="guide.md", doc_id="g2"),
|
|
_chunk("intro body.", source="notes/intro.md", doc_id="i1"),
|
|
],
|
|
)
|
|
delay = _patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "converted"
|
|
deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
|
|
assert deleted == {"g1", "g2", "i1"}
|
|
assert delay.call_count == 2
|
|
|
|
def test_chunk_without_doc_id_not_deleted(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
chunk = {"text": "body", "metadata": {"source": "a.md"}}
|
|
store = _patch_store(monkeypatch, [chunk])
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "converted"
|
|
store.delete_chunk.assert_not_called()
|
|
|
|
def test_skipped_chunk_with_doc_id_still_deleted(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
# A chunk skipped for an invalid path must still have its original
|
|
# vector chunk purged, not left orphaned after the source flips to wiki.
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
store = _patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("good", source="ok.md", doc_id="ok"),
|
|
_chunk("bad", source="../evil.md", doc_id="evil"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["pages_created"] == 1
|
|
deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
|
|
assert deleted == {"ok", "evil"}
|
|
|
|
def test_no_pages_path_deletes_nothing(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
store = _patch_store(monkeypatch, [])
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "no_pages"
|
|
store.delete_chunk.assert_not_called()
|
|
|
|
def test_kind_flipped_and_exposure_defaulted(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
from application.storage.db.source_config import SourceConfig
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(monkeypatch, [_chunk("body", source="a.md")])
|
|
_patch_reembed(monkeypatch)
|
|
|
|
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
|
|
cfg = SourceConfig.parse(refreshed.get("config"))
|
|
assert cfg.kind == "wiki"
|
|
assert cfg.retrieval.exposure == "agentic_tool"
|
|
|
|
def test_preserves_non_default_exposure(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
from application.storage.db.source_config import SourceConfig
|
|
|
|
source_id = _seed_source(
|
|
pg_conn, config={"retrieval": {"exposure": "agentic_tool"}}
|
|
)
|
|
_patch_store(monkeypatch, [_chunk("body", source="a.md")])
|
|
_patch_reembed(monkeypatch)
|
|
|
|
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
|
|
cfg = SourceConfig.parse(refreshed.get("config"))
|
|
assert cfg.kind == "wiki"
|
|
assert cfg.retrieval.exposure == "agentic_tool"
|
|
|
|
def test_chunk_order_hint_respected(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("second", source="a.md", doc_id="a2", chunk_index=1),
|
|
_chunk("first", source="a.md", doc_id="a1", chunk_index=0),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
by_path = {p["path"]: p for p in pages}
|
|
assert by_path["/a.md"]["content"] == "first\n\nsecond"
|
|
|
|
def test_short_overlap_not_trimmed(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("The end.", source="a.md", doc_id="a1"),
|
|
_chunk("The end. More", source="a.md", doc_id="a2"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
by_path = {p["path"]: p for p in pages}
|
|
assert by_path["/a.md"]["content"] == "The end.\n\nThe end. More"
|
|
|
|
def test_long_overlap_trimmed_between_chunks(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
overlap = "x" * 40
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("head " + overlap, source="a.md", doc_id="a1"),
|
|
_chunk(overlap + " tail", source="a.md", doc_id="a2"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
by_path = {p["path"]: p for p in pages}
|
|
assert by_path["/a.md"]["content"] == "head " + overlap + "\n\n tail"
|
|
|
|
def test_no_chunks_leaves_kind_and_structure(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
from application.storage.db.source_config import SourceConfig
|
|
|
|
original_structure = {"a.md": {"type": "text/markdown", "size_bytes": 1}}
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(monkeypatch, [])
|
|
rebuild = MagicMock(name="rebuild")
|
|
monkeypatch.setattr(worker, "rebuild_wiki_directory_structure", rebuild)
|
|
delay = _patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "no_pages"
|
|
assert result["pages_created"] == 0
|
|
rebuild.assert_not_called()
|
|
delay.assert_not_called()
|
|
|
|
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
|
|
assert SourceConfig.parse(refreshed.get("config")).kind != "wiki"
|
|
assert refreshed.get("directory_structure") == original_structure
|
|
|
|
def test_missing_path_chunk_skipped(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("good", source="ok.md"),
|
|
_chunk("orphan", source=None),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["pages_created"] == 1
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/ok.md"}
|
|
assert any(s["reason"] == "missing path" for s in result["skipped"])
|
|
|
|
def test_invalid_path_chunk_skipped(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("good", source="ok.md"),
|
|
_chunk("bad", source="../evil.md"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["pages_created"] == 1
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/ok.md"}
|
|
skipped = {(s["file"], s["reason"]) for s in result["skipped"]}
|
|
assert ("../evil.md", "invalid path") in skipped
|
|
|
|
def test_path_falls_back_to_filename_then_title(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("from filename", source=None, filename="byname.md"),
|
|
_chunk("from title", source=None, title="bytitle.md"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["pages_created"] == 2
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/byname.md", "/bytitle.md"}
|
|
|
|
def test_crawler_chunk_uses_file_path_not_url(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk(
|
|
"setup body",
|
|
source="https://docs.x.com/guides/setup",
|
|
file_path="guides/setup.md",
|
|
),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "converted"
|
|
assert result["skipped"] == []
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/guides/setup.md"}
|
|
|
|
def test_connector_chunks_kept_separate_by_file_name(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[
|
|
_chunk("page a", source="confluence", file_name="Page A", doc_id="a"),
|
|
_chunk("page b", source="confluence", file_name="Page B", doc_id="b"),
|
|
],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["pages_created"] == 2
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/Page A", "/Page B"}
|
|
|
|
def test_url_only_chunk_normalized_not_skipped(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn)
|
|
_patch_store(
|
|
monkeypatch,
|
|
[_chunk("body", source="https://x.com/a/b")],
|
|
)
|
|
_patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "converted"
|
|
assert result["skipped"] == []
|
|
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
|
|
assert {p["path"] for p in pages} == {"/a/b"}
|
|
|
|
def test_already_wiki_returns_early(
|
|
self, pg_conn, patch_worker_db, task_self, monkeypatch
|
|
):
|
|
from application import worker
|
|
|
|
source_id = _seed_source(pg_conn, config={"kind": "wiki"})
|
|
store = _patch_store(monkeypatch, [_chunk("body", source="a.md")])
|
|
delay = _patch_reembed(monkeypatch)
|
|
|
|
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
|
|
|
|
assert result["status"] == "already_wiki"
|
|
store.get_chunks.assert_not_called()
|
|
delay.assert_not_called()
|