"""目录、互链,以及每一种提案落到知识库上到底做了什么。 这一批里最要紧的两条不变量: * **镜像条目改不动。** 飞书是它唯一的编辑面,在 Helix 改会被下一轮同步覆盖 —— 所以接受这类提案必须**明确拒绝**,而不是看起来成功、然后悄悄什么都没发生。 * **对称的关系只存一行。** 两行表示一个事实就是两行可以互相矛盾,而只查一个 方向的读者会看到半张图。 """ from __future__ import annotations import uuid import pytest from sqlalchemy import func, select from app.core.knowledge_loop import service as loop from app.core.knowledge_loop.constants import ( CORPUS_SUPPORT_KB, KIND_ADD_ALIASES, KIND_APPEND_SECTION, KIND_CONTRADICTION, KIND_MERGE, KIND_NEW_ARTICLE, KIND_RETIRE, SOURCE_INGEST_TICKET, SOURCE_LINT, STATUS_ACCEPTED, STATUS_FAILED, ) from app.domains.support import kb_wiki from app.domains.support.models import ( KB_ARCHIVED, KB_DRAFT, LINK_CONTRADICTS, LINK_RELATED, LINK_SUPERSEDES, SupportKbArticle, SupportKbArticleLink, ) from tests.test_knowledge_loop.conftest import ( make_article, make_mirror, make_person, ) pytestmark = pytest.mark.asyncio # ── 目录 ──────────────────────────────────────────────────────────── async def test_the_index_falls_back_to_the_body_when_there_is_no_summary(db_session): """🔴 目录必须对**今天的语料**可用,而不是只对跑过摘要之后的语料可用。""" await make_article( db_session, title="VPN 使用说明", body=" 公司 VPN 使用 Cisco AnyConnect,\n步骤如下。", summary=None, ) await db_session.commit() entries = await kb_wiki.index(db_session, use_cache=False) entry = next(e for e in entries if e["title"] == "VPN 使用说明") assert entry["summary"].startswith("公司 VPN 使用 Cisco AnyConnect") # 而且要如实说明这是回落值,不是有人写的说明。 assert entry["has_summary"] is False async def test_a_written_summary_wins(db_session): await make_article( db_session, title="VPN 使用说明", body="正文", summary="讲怎么连上公司 VPN。", ) await db_session.commit() entries = await kb_wiki.index(db_session, use_cache=False) entry = next(e for e in entries if e["title"] == "VPN 使用说明") assert entry["summary"] == "讲怎么连上公司 VPN。" assert entry["has_summary"] is True async def test_the_index_only_lists_published_pages(db_session): await make_article(db_session, title="发布的") await make_article(db_session, title="草稿的", status=KB_DRAFT) await make_article(db_session, title="下架的", status=KB_ARCHIVED) await db_session.commit() titles = {e["title"] for e in await kb_wiki.index(db_session, use_cache=False)} assert titles == {"发布的"} async def test_index_health_counts_what_is_missing(db_session): await make_article(db_session, title="有摘要有别名", summary="s", aliases="a") await make_article(db_session, title="什么都没有") await db_session.commit() health = await kb_wiki.index_health(db_session) assert health["articles"] == 2 assert health["with_summary"] == 1 assert health["with_aliases"] == 1 # ── 互链 ──────────────────────────────────────────────────────────── async def test_a_symmetric_link_is_stored_once_whichever_way_it_is_written( db_session, ): """🔴 两行表示一个事实,就是两行可以互相矛盾。""" a = await make_article(db_session, title="A") b = await make_article(db_session, title="B") await db_session.commit() first = await kb_wiki.link_articles( db_session, from_id=a.id, to_id=b.id, kind=LINK_RELATED, ) reversed_write = await kb_wiki.link_articles( db_session, from_id=b.id, to_id=a.id, kind=LINK_RELATED, ) await db_session.commit() assert first is not None assert reversed_write is None, "反向写又存了一行" total = ( await db_session.execute( select(func.count()).select_from(SupportKbArticleLink) ) ).scalar() assert total == 1 async def test_an_asymmetric_link_keeps_its_direction(db_session): """supersedes 是有方向的:谁取代了谁,不能被规范化掉。""" survivor = await make_article(db_session, title="保留") absorbed = await make_article(db_session, title="被合并") await db_session.commit() await kb_wiki.link_articles( db_session, from_id=survivor.id, to_id=absorbed.id, kind=LINK_SUPERSEDES, ) reverse = await kb_wiki.link_articles( db_session, from_id=absorbed.id, to_id=survivor.id, kind=LINK_SUPERSEDES, ) await db_session.commit() # 反方向是一个**不同的**事实,允许存在(虽然是错的),不能被静默吞掉。 assert reverse is not None async def test_a_page_cannot_be_linked_to_itself(db_session): a = await make_article(db_session, title="A") await db_session.commit() assert ( await kb_wiki.link_articles(db_session, from_id=a.id, to_id=a.id) is None ) async def test_links_for_reads_both_directions_with_the_other_title(db_session): a = await make_article(db_session, title="A") b = await make_article(db_session, title="B") await db_session.commit() a_id, b_id = a.id, b.id await kb_wiki.link_articles( db_session, from_id=a_id, to_id=b_id, kind=LINK_RELATED, ) await db_session.commit() from_a = await kb_wiki.links_for(db_session, article_id=a_id) from_b = await kb_wiki.links_for(db_session, article_id=b_id) assert len(from_a) == 1 and from_a[0]["other_title"] == "B" assert len(from_b) == 1 and from_b[0]["other_title"] == "A" async def test_expansion_adds_related_pages_but_not_contradicting_ones(db_session): """🔴 跟着 contradicts 走,等于把语料自己知道是错的那篇递给模型。""" hit = await make_article(db_session, title="命中的") friend = await make_article(db_session, title="相关的") enemy = await make_article(db_session, title="冲突的") await db_session.commit() await kb_wiki.link_articles( db_session, from_id=hit.id, to_id=friend.id, kind=LINK_RELATED, ) await kb_wiki.link_articles( db_session, from_id=hit.id, to_id=enemy.id, kind=LINK_CONTRADICTS, ) await db_session.commit() expanded = await kb_wiki.expand_with_links( db_session, articles=[hit], limit=5, ) titles = {article.title for article in expanded} assert titles == {"命中的", "相关的"} async def test_expansion_never_exceeds_the_limit(db_session): hit = await make_article(db_session, title="命中的") await db_session.commit() for n in range(4): friend = await make_article(db_session, title=f"相关 {n}") await db_session.flush() await kb_wiki.link_articles( db_session, from_id=hit.id, to_id=friend.id, kind=LINK_RELATED, ) await db_session.commit() expanded = await kb_wiki.expand_with_links(db_session, articles=[hit], limit=3) assert len(expanded) == 3 async def test_expansion_skips_an_archived_neighbour(db_session): """下架的邻居不是答案 —— 下架的全部意义就是把它拿出可及范围。""" hit = await make_article(db_session, title="命中的") gone = await make_article(db_session, title="已下架", status=KB_ARCHIVED) await db_session.commit() await kb_wiki.link_articles( db_session, from_id=hit.id, to_id=gone.id, kind=LINK_RELATED, ) await db_session.commit() expanded = await kb_wiki.expand_with_links(db_session, articles=[hit], limit=5) assert [a.title for a in expanded] == ["命中的"] # ── 提案落地 ──────────────────────────────────────────────────────── async def _decide(db, proposal, person): return await loop.decide( db, proposal=proposal, accept=True, actor_person_id=person.id, ) async def test_accepting_a_new_article_creates_a_draft_not_a_published_page( db_session, ): """机器写的东西不能自己上线。""" person = await make_person(db_session) await db_session.commit() proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_NEW_ARTICLE, title="新条目", source=SOURCE_INGEST_TICKET, payload={"title": "怎么连打印机", "body": "步骤如下。", "category": "hardware"}, ) result = await _decide(db_session, proposal, person) await db_session.commit() assert result["status"] == STATUS_ACCEPTED created = await db_session.get(SupportKbArticle, uuid.UUID(result["applied_ref"])) assert created.status == KB_DRAFT assert created.title == "怎么连打印机" async def test_appending_a_section_is_idempotent(db_session): """评审人的点击是可重试的,重试不能把同一段贴两遍。""" article = await make_article(db_session, title="报修说明", body="原有正文。") person = await make_person(db_session) await db_session.commit() article_id = article.id def _proposal(): return { "corpus": CORPUS_SUPPORT_KB, "kind": KIND_APPEND_SECTION, "title": "追加", "source": SOURCE_INGEST_TICKET, "target_ref": str(article_id), "payload": {"heading": "外接显示器", "body": "先换一根线试试。"}, } first = await loop.propose(db_session, **_proposal()) await _decide(db_session, first, person) await db_session.commit() second = await loop.propose(db_session, **_proposal()) result = await _decide(db_session, second, person) await db_session.commit() assert result.get("noop") is True db_session.expire_all() fresh = await db_session.get(SupportKbArticle, article_id) assert fresh.body.count("先换一根线试试。") == 1 assert "## 外接显示器" in fresh.body async def test_merging_archives_the_absorbed_page_and_records_what_it_took( db_session, ): keep = await make_article(db_session, title="电脑报修", aliases="报修") drop = await make_article(db_session, title="笔记本故障处理", aliases="笔记本坏了") person = await make_person(db_session) await db_session.commit() keep_id, drop_id = keep.id, drop.id proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_MERGE, title="合并", source=SOURCE_LINT, target_ref=str(keep_id), related_ref=str(drop_id), ) result = await _decide(db_session, proposal, person) await db_session.commit() assert result["status"] == STATUS_ACCEPTED db_session.expire_all() survivor = await db_session.get(SupportKbArticle, keep_id) absorbed = await db_session.get(SupportKbArticle, drop_id) assert absorbed.status == KB_ARCHIVED # 被吸收那条的标题和别名要变成保留那条的说法 —— 否则合并等于丢召回。 aliases = survivor.search_aliases.splitlines() assert "笔记本故障处理" in aliases assert "笔记本坏了" in aliases links = await kb_wiki.links_for(db_session, article_id=keep_id) assert any(link["kind"] == LINK_SUPERSEDES for link in links) async def test_a_contradiction_with_no_second_page_files_the_incoming_text( db_session, ): """来自 ingest 的冲突还没有第二篇 —— 接受要把它落成草稿并链起来。""" existing = await make_article(db_session, title="回购说明", body="满 2 年可回购。") person = await make_person(db_session) await db_session.commit() existing_id = existing.id proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_CONTRADICTION, title="冲突", source=SOURCE_INGEST_TICKET, target_ref=str(existing_id), payload={"incoming_title": "回购年限", "incoming_body": "折旧年限为 36 个月。"}, ) result = await _decide(db_session, proposal, person) await db_session.commit() assert result["status"] == STATUS_ACCEPTED links = await kb_wiki.links_for(db_session, article_id=existing_id) assert any(link["kind"] == LINK_CONTRADICTS for link in links) # 冲突**不选边**:两条都还在,只是被摆到了一起。 db_session.expire_all() left = await db_session.get(SupportKbArticle, existing_id) assert left.status != KB_ARCHIVED async def test_retiring_archives_and_invalidates(db_session): article = await make_article(db_session, title="过时的", embedding=[0.1, 0.2]) person = await make_person(db_session) await db_session.commit() article_id = article.id proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_RETIRE, title="下架", source=SOURCE_LINT, target_ref=str(article_id), ) await _decide(db_session, proposal, person) await db_session.commit() db_session.expire_all() fresh = await db_session.get(SupportKbArticle, article_id) assert fresh.status == KB_ARCHIVED assert fresh.embedding is None # ── 镜像条目 ──────────────────────────────────────────────────────── @pytest.mark.parametrize( "kind,payload", [ (KIND_ADD_ALIASES, {"aliases": ["某说法"]}), (KIND_APPEND_SECTION, {"heading": "补充", "body": "一段。"}), (KIND_RETIRE, None), ], ) async def test_a_mirrored_page_refuses_every_edit(db_session, kind, payload): """🔴 飞书是镜像条目唯一的编辑面。 在 Helix 改会被下一轮同步覆盖 —— 所以「接受成功、其实什么都没发生」是这里 最坏的结局:它会让人以为改过了。必须明确失败并说清去哪儿改。 """ mirror = await make_mirror(db_session, title="飞书镜像页") person = await make_person(db_session) await db_session.commit() mirror_id = mirror.id proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=kind, title="改镜像", source=SOURCE_LINT, target_ref=str(mirror_id), payload=payload, ) result = await _decide(db_session, proposal, person) await db_session.commit() assert result["status"] == STATUS_FAILED assert "飞书" in (proposal.error or "") db_session.expire_all() fresh = await db_session.get(SupportKbArticle, mirror_id) assert fresh.status != KB_ARCHIVED assert fresh.search_aliases is None async def test_describe_reads_the_page_as_it_stands_today(db_session): """🔴 评审人要判的是**现在**那一页,不是提案写下时的快照。""" article = await make_article(db_session, title="原标题", body="原正文") await db_session.commit() article_id = article.id proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_ADD_ALIASES, title="补说法", source=SOURCE_LINT, target_ref=str(article_id), payload={"aliases": ["x"]}, ) await db_session.commit() fresh = await db_session.get(SupportKbArticle, article_id) fresh.title = "被人改过的标题" await db_session.commit() adapter = kb_wiki.SupportKbAdapter() described = await adapter.describe(db_session, proposal=proposal) assert described["target"]["title"] == "被人改过的标题" async def test_describe_flags_a_mirror_as_read_only(db_session): """界面靠这个字段禁用接受按钮,而不是让人做一个会蒸发的改动。""" mirror = await make_mirror(db_session, title="镜像页") await db_session.commit() proposal = await loop.propose( db_session, corpus=CORPUS_SUPPORT_KB, kind=KIND_ADD_ALIASES, title="补说法", source=SOURCE_LINT, target_ref=str(mirror.id), payload={"aliases": ["x"]}, ) await db_session.commit() described = await kb_wiki.SupportKbAdapter().describe( db_session, proposal=proposal, ) assert described["target"]["read_only"] is True