import shutil import tempfile import unittest from pathlib import Path from sqlmodel import Session, SQLModel, create_engine, select from app import planner from app.agents import runner from app.rag import dedup from app.rag.pipeline import iter_lightweight_chunks from app.models import ( Agent, Campaign, CampaignPlanVersion, KnowledgeBase, KnowledgeFile, KnowledgeUsage, Segment, ) def parsed_segments(): return { "status": "ok", "segments": [ {"title": "开场建立信任", "content_tag": "开场", "time_range": "00:00-00:05", "mindmap_node": "开场:欢迎与今天主题", "product_ref": ""}, {"title": "核心心法讲解", "content_tag": "心法", "time_range": "00:05-00:10", "mindmap_node": "主体:核心心法", "product_ref": ""}, ], "pending_confirms": [], "notes": [], "provider": "test", "model": "mock-model", } class PlannerVersionTests(unittest.TestCase): def setUp(self): self.engine = create_engine("sqlite://") SQLModel.metadata.create_all(self.engine) self.db = Session(self.engine) self.tempdir = Path(tempfile.mkdtemp(prefix="yawan-planner-kb-")) source_dir = self.tempdir / "kb" source_dir.mkdir(parents=True, exist_ok=True) materials = { "开场素材.txt": ( "开场信任欢迎主题素材。开场要先建立信任,用温和欢迎语说明今天主题。" "开场阶段必须让听众感到安全、被看见、愿意继续听下去。" "欢迎语不能夸大承诺,也不能制造焦虑,只能围绕信任和陪伴展开。" ) * 3, "心法素材.txt": ( "核心心法讲解素材。主体部分解释核心心法,用日常语言说明练习步骤和身体感受。" "心法讲解要回到呼吸、放松、觉察这些可验证体验,不写素材之外的功效。" "每个结论都必须能在核心心法素材中找到来源。" ) * 3, } for file_name, body in materials.items(): path = source_dir / file_name path.write_text(body, encoding="utf-8") # 禁用本地向量:chunk_count 为 0,Planner 必须从登记源文件临时切分并预演。 self.kb = KnowledgeBase( name="测试知识库", index_status="disabled", file_count=len(materials), chunk_count=0, source_dir=str(source_dir), organization_id=1, owner_user_id=0, visibility="global") self.agent = Agent(name="测试生产 Agent", role="production", status="active", content_tags=["开场", "心法", "收尾"], kb_ids=[1]) self.db.add_all([self.kb, self.agent]) self.db.commit() self.db.refresh(self.kb) self.db.refresh(self.agent) self.agent.kb_ids = [self.kb.id] self.db.add(self.agent) for file_name in materials: path = source_dir / file_name self.db.add(KnowledgeFile( kb_id=self.kb.id, file_name=file_name, content_hash=dedup.file_hash(path), revision=1)) self.campaign = Campaign( name="计划版本测试", mindmap_text="开场 -> 心法", objective="完成测试直播", success_criteria="只使用知识库", duration_minutes=10, status="draft", owner_user_id=0, ) self.db.add(self.campaign) self.db.commit() self.db.refresh(self.campaign) self._original_parse = planner.parse_mindmap planner.parse_mindmap = lambda *args, **kwargs: parsed_segments() def tearDown(self): planner.parse_mindmap = self._original_parse self.db.close() self.engine.dispose() shutil.rmtree(self.tempdir, ignore_errors=True) def test_dag_cycle_is_rejected(self): with self.assertRaisesRegex(ValueError, "环"): planner.topo_waves(["n1", "n2"], [{"from": "n1", "to": "n2"}, {"from": "n2", "to": "n1"}]) def test_planner_creates_validated_version_and_approval_materializes_segments(self): result = planner.run_plan_job(self.db, self.campaign.id) self.assertEqual(result["status"], "waiting_review") self.db.refresh(self.campaign) self.assertEqual(self.campaign.status, "plan_review") version = self.db.get(CampaignPlanVersion, result["plan_version_id"]) self.assertEqual(version.version, 1) self.assertEqual(len(version.plan["nodes"]), 2) self.assertEqual(version.plan["edges"], [{"from": "n1", "to": "n2"}]) self.assertEqual(version.validation["waves"], [["n1"], ["n2"]]) self.assertTrue(version.validation["ok"]) approved = planner.approve_plan(self.db, version.id, reviewer_user_id=1) self.db.refresh(self.campaign) self.assertEqual(approved.status, "approved") self.assertEqual(self.campaign.status, "parsed") self.assertEqual(self.campaign.approved_plan_version_id, approved.id) self.assertEqual(self.campaign.canvas_snapshot["plan_version_id"], approved.id) segments = self.db.exec(select(Segment).where(Segment.campaign_id == self.campaign.id)).all() self.assertEqual([s.node_id for s in segments], ["n1", "n2"]) self.assertEqual([s.agent_id for s in segments], [self.agent.id, self.agent.id]) self.assertEqual([s.content_tag for s in segments], ["开场", "心法"]) def test_worker_rejects_legacy_campaign_without_approved_plan_in_dsh_mode(self): self.campaign.status = "parsed" self.db.add(self.campaign) self.db.commit() outcome = runner.run_campaign(self.db, self.campaign.id) self.assertEqual(outcome["status"], "error") self.assertIn("人工批准", outcome["error"]) def test_worker_rejects_canvas_that_drifted_from_approved_version(self): result = planner.run_plan_job(self.db, self.campaign.id) planner.approve_plan(self.db, result["plan_version_id"], reviewer_user_id=1) self.db.refresh(self.campaign) self.campaign.status = "running" snapshot = dict(self.campaign.canvas_snapshot) snapshot["plan_version_id"] = 999999 self.campaign.canvas_snapshot = snapshot self.db.add(self.campaign) self.db.commit() outcome = runner.run_campaign(self.db, self.campaign.id) self.assertEqual(outcome["status"], "error") self.assertIn("画布快照", outcome["error"]) def test_empty_knowledge_base_fails_validation_and_never_materializes_segments(self): self.kb.file_count = 0 self.kb.chunk_count = 0 self.db.add(self.kb) self.db.commit() result = planner.run_plan_job(self.db, self.campaign.id) self.assertEqual(result["status"], "failed") self.db.refresh(self.campaign) self.assertEqual(self.campaign.status, "plan_failed") version = self.db.get(CampaignPlanVersion, result["plan_version_id"]) self.assertTrue(version.validation["errors"]) self.assertEqual(self.db.exec(select(Segment)).all(), []) with self.assertRaisesRegex(ValueError, "不能批准"): planner.approve_plan(self.db, version.id) def test_consumed_lightweight_chunks_block_new_plan_before_worker_runs(self): chunks = iter_lightweight_chunks(self.db, [self.kb.id]) self.assertEqual(len(chunks), 2) for item in chunks: self.db.add(KnowledgeUsage( agent_id=self.agent.id, kb_id=self.kb.id, chunk_id=item["id"], file_name=item["meta"].get("file", ""), excerpt=item["text"][:300], campaign_id=999, segment_id=999, status="consumed")) self.db.commit() result = planner.run_plan_job(self.db, self.campaign.id) self.assertEqual(result["status"], "failed") version = self.db.get(CampaignPlanVersion, result["plan_version_id"]) codes = {item["code"] for item in version.validation["errors"]} self.assertTrue({"knowledge_exhausted", "knowledge_mismatch"} & codes) self.assertEqual(self.db.exec(select(Segment)).all(), []) def test_unassigned_agent_is_blocking_error(self): self.agent.status = "disabled" self.db.add(self.agent) self.db.commit() result = planner.run_plan_job(self.db, self.campaign.id) version = self.db.get(CampaignPlanVersion, result["plan_version_id"]) codes = {item["code"] for item in version.validation["errors"]} self.assertIn("unassigned_agent", codes) if __name__ == "__main__": unittest.main()