import json from types import SimpleNamespace import pytest from sqlmodel import SQLModel, Session, create_engine from app import training_corpus as corpus from app.models import KnowledgeBase, KnowledgeFile from app.rag import store @pytest.fixture def scope(tmp_path, monkeypatch): monkeypatch.setattr(corpus, "CORPUS_DIR", tmp_path / "corpora") monkeypatch.setattr(store, "FALLBACK_DIR", tmp_path / "fallback") store.FALLBACK_DIR.mkdir() engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: yield session, tmp_path engine.dispose() def _kb(session, name="资料库", **extra): row = KnowledgeBase(name=name, index_status="ready", **extra) session.add(row) session.commit() return row def _draft(kbs, identifier=1): return SimpleNamespace(id=identifier, version=1, kb_ids=[kb.id for kb in kbs], topic="分数教学") def _chunks(kb, count, text_size=30): return [{"id": f"chunk-{index}", "text": f"第{index}条定义和步骤" + "材料" * text_size, "meta": {"kb_id": kb.id, "file": "source.txt"}} for index in range(count)] def test_small_corpus_includes_sources_after_top_40_without_extra_model_calls(scope, monkeypatch): session, _ = scope kb = _kb(session) chunks = _chunks(kb, 55, 0) monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(chunks)) def no_model(*args): raise AssertionError("小库无需额外摘要调用") evidence, coverage = corpus.build_corpus(session, _draft([kb]), no_model, None) assert len(evidence) == 55 and evidence[-1]["chunk_id"] == "chunk-54" assert coverage["scanned_chunks"] == 55 assert coverage["read_chunks"] == 0 and coverage["queued_raw_chunks"] == 55 assert coverage["phase"] == coverage["stage"] == "planning" assert coverage["complete"] is False page = corpus.source_page(_draft([kb]), evidence[-1]["id"]) assert page["total"] == 1 and page["items"][0]["text"] == chunks[-1]["text"] def test_full_batches_read_all_libraries_and_preserve_provenance(scope, monkeypatch): session, _ = scope first, second = _kb(session, "大资料库"), _kb(session, "小资料库") data = {first.id: _chunks(first, 70, 300), second.id: _chunks(second, 2, 10)} visited, fed, snapshots = [], [], [] def iterate(kid): visited.append(kid) return iter(data[kid]) monkeypatch.setattr(corpus, "iter_chunks", iterate) def summarize(evidence, max_chars): assert len(json.dumps(evidence, ensure_ascii=False)) <= 16000 fed.extend(item["chunk_id"] for item in evidence if item["kind"] == "raw") return "资料覆盖:" + "、".join(item["chunk_id"] for item in evidence)[:max_chars - 6] row = _draft([first, second]) evidence, coverage = corpus.build_corpus(session, row, summarize, snapshots.append) assert visited == [first.id, second.id] assert set(item["kb_id"] for item in evidence) == set(row.kb_ids) assert coverage["read_chunks"] == 72 and coverage["scanned_chunks"] == 72 assert len(fed) == 72 and "chunk-69" in fed assert len(json.dumps(evidence, ensure_ascii=False)) <= 40000 seen = set() for item in evidence: page = corpus.source_page(row, item["id"], 0, 100) assert page["total"] == item["source_count"] seen.update((source["kb_id"], source["chunk_id"]) for source in page["items"]) assert len(seen) == 72 assert any(snapshot["batches_done"] for snapshot in snapshots) assert coverage["kbs"] == coverage["per_kb"] def test_recursive_reduction_is_bounded_and_long_chunk_tail_is_read(scope, monkeypatch): session, _ = scope kb = _kb(session) monkeypatch.setattr(corpus, "BATCH_CHARS", 900) monkeypatch.setattr(corpus, "FINAL_CHARS", 1800) original = ("引号\"和换行\n" * 350) + "不可丢失的最后一句:禁止省略前提。" data = _chunks(kb, 16, 80) + [{"id": "long", "text": original, "meta": {"file": "long.txt"}}] monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(data)) long_parts, levels = [], [] def summarize(evidence, max_chars): assert len(json.dumps(evidence, ensure_ascii=False)) <= 900 for item in evidence: if item["chunk_id"] == "long": long_parts.append(item["text"]) levels.append(any(item["kind"] == "summary" for item in evidence)) return "保留资料中概念定义、练习步骤、适用前提与内容限制。" * 5 row = _draft([kb]) evidence, coverage = corpus.build_corpus(session, row, summarize, None) assert "".join(long_parts) == original assert any(levels) assert coverage["read_chunks"] == 17 assert len(json.dumps(evidence, ensure_ascii=False)) <= 1800 sources = [source for item in evidence for source in corpus.source_page(row, item["id"], 0, 100)["items"]] assert next(source for source in sources if source["chunk_id"] == "long")["text"] == original def test_unindexed_disk_and_registered_files_are_reported(scope, monkeypatch): session, directory = scope source = directory / "source" source.mkdir() (source / "source.txt").write_text("已索引", encoding="utf-8") (source / "new.txt").write_text("未索引新资料", encoding="utf-8") kb = _kb(session, source_dir=str(source)) session.add(KnowledgeFile(kb_id=kb.id, file_name="registered-only.txt")) session.commit() monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(_chunks(kb, 1))) _, coverage = corpus.build_corpus(session, _draft([kb]), None, None) assert coverage["total_files"] == 3 and coverage["indexed_files"] == 1 assert coverage["unindexed_files"] == 2 and not coverage["source_complete"] assert coverage["per_kb"][0]["unindexed_files"] == ["new.txt", "registered-only.txt"] assert coverage["warnings"] def test_indexing_and_read_failures_never_claim_completion(scope, monkeypatch): session, _ = scope kb = _kb(session) kb.index_status = "indexing" session.add(kb) session.commit() snapshots = [] monkeypatch.setattr(corpus, "iter_chunks", lambda kid: pytest.fail("不能读取正在重建的索引")) with pytest.raises(ValueError, match="正在建立索引"): corpus.build_corpus(session, _draft([kb]), None, snapshots.append) assert snapshots[-1]["phase"] == "failed" and not snapshots[-1]["complete"] kb.index_status = "ready" session.add(kb) session.commit() def broken(kid): yield from _chunks(kb, 1) raise RuntimeError("索引后半部损坏") monkeypatch.setattr(corpus, "iter_chunks", broken) with pytest.raises(RuntimeError, match="后半部"): corpus.build_corpus(session, _draft([kb]), None, snapshots.append) assert snapshots[-1]["scanned_chunks"] == 1 assert snapshots[-1]["read_chunks"] == 0 and snapshots[-1]["phase"] == "failed" def test_summary_failure_and_overbudget_are_not_silently_truncated(scope, monkeypatch): session, _ = scope kb = _kb(session) monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(_chunks(kb, 70, 150))) snapshots = [] with pytest.raises(ValueError, match="摘要超出"): corpus.build_corpus(session, _draft([kb]), lambda evidence, max_chars: "字" * (max_chars + 1), snapshots.append) assert snapshots[-1]["read_chunks"] == 0 assert snapshots[-1]["phase"] == "failed" assert not corpus._path(1, 1).exists() def test_duplicate_chunk_identity_counted_but_conflicting_duplicate_fails(scope, monkeypatch): session, _ = scope kb = _kb(session) data = _chunks(kb, 1) monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(data + data)) evidence, coverage = corpus.build_corpus(session, _draft([kb]), None, None) assert len(evidence) == 1 and coverage["duplicate_chunks"] == 1 assert coverage["scanned_chunks"] == 2 and coverage["queued_raw_chunks"] == 1 monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(data + [{**data[0], "text": "冲突正文"}])) with pytest.raises(ValueError, match="正文冲突"): corpus.build_corpus(session, _draft([kb]), None, None) def test_fallback_stream_does_not_materialize_embeddings_and_rejects_broken_tail(scope, monkeypatch): session, _ = scope kb = _kb(session) data = _chunks(kb, 60) for item in data: item["embedding"] = [0.314159] * 300 store._write_fallback(kb.id, data) monkeypatch.setattr(store, "_read_fallback", lambda kid: pytest.fail("不得整库加载 fallback")) original_loads = store.json.loads def guarded_loads(value, *args, **kwargs): assert "0.314159" not in value return original_loads(value, *args, **kwargs) monkeypatch.setattr(store.json, "loads", guarded_loads) streamed = list(store.iter_chunks(kb.id)) assert len(streamed) == 60 and streamed[-1]["id"] == "chunk-59" assert all("embedding" not in item for item in streamed) with store._fallback_path(kb.id).open("a") as handle: handle.write("malformed-tail") with pytest.raises(RuntimeError, match="多余数据"): list(store.iter_chunks(kb.id)) def test_chroma_full_pagination_requests_only_documents_and_metadata(scope, monkeypatch): session, _ = scope kb = _kb(session) data, offsets = _chunks(kb, 61), [] class Collection: def count(self): return len(data) def get(self, *, include, limit, offset): assert include == ["documents", "metadatas"] offsets.append(offset) page = data[offset:offset + limit] return {"ids": [item["id"] for item in page], "documents": [item["text"] for item in page], "metadatas": [item["meta"] for item in page]} monkeypatch.setattr(store, "get_client", lambda: SimpleNamespace(get_collection=lambda name: Collection())) assert len(list(store.iter_chunks(kb.id, page_size=17))) == 61 assert offsets == [0, 17, 34, 51] def test_sources_pagination_path_checks_day_expansion_and_delete(scope, monkeypatch): session, _ = scope kb = _kb(session) data = _chunks(kb, 80, 70) monkeypatch.setattr(corpus, "iter_chunks", lambda kid: iter(data)) row = _draft([kb]) evidence, _ = corpus.build_corpus(session, row, lambda evidence, max_chars: "资料定义和必要前提", None) first = evidence[0] page = corpus.source_page(row, first["id"], 1, 2) assert len(page["items"]) == 2 and page["offset"] == 1 and page["limit"] == 2 assert page["items"][0]["chunk_id"] == "chunk-1" expanded = corpus.day_evidence(row, evidence) assert expanded[:len(evidence)] == evidence assert any(item["kind"] == "raw" for item in expanded) assert len(json.dumps(expanded, ensure_ascii=False)) <= 40000 for bad in ["../v1.sqlite", "E0", "other"]: with pytest.raises(ValueError): corpus.source_page(row, bad) with pytest.raises(ValueError): corpus.source_page(row, first["id"], -1) with pytest.raises(ValueError): corpus.remove_corpus("../1") corpus.remove_corpus(row.id) assert not corpus._path(row.id, row.version).exists() def test_summary_cache_reuses_exact_batches_across_versions_but_not_topic_or_model(scope): _, _ = scope row = SimpleNamespace(id=1, version=1, owner_user_id=7, organization_id=1, topic='分数教学', requested_days=None) entries = [{'id': 'E1', 'text': '第一批原文'}] calls = [] def generate(): calls.append(True) return '原文依据与教学步骤' assert corpus.cached_summary(row, entries, 100, 'model-a', generate)[1] is False row.version = 2 assert corpus.cached_summary(row, entries, 100, 'model-a', generate)[1] is True assert len(calls) == 1 assert corpus.cached_summary(row, entries, 100, 'model-b', generate)[1] is False row.topic = '小数教学' assert corpus.cached_summary(row, entries, 100, 'model-a', generate)[1] is False entries[0]['text'] = '原文已更新' assert corpus.cached_summary(row, entries, 100, 'model-a', generate)[1] is False assert len(calls) == 4 with pytest.raises(ValueError): corpus.cached_summary(row, entries, 1, 'model-a', generate) corpus.remove_corpus(1) assert not (corpus.CORPUS_DIR / '1').exists()