iatest-framework 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. ai_apitest/__init__.py +3 -0
  2. ai_apitest/__main__.py +4 -0
  3. ai_apitest/agents/__init__.py +5 -0
  4. ai_apitest/agents/identities/api_analyst.yaml +29 -0
  5. ai_apitest/agents/identities/case_designer.yaml +41 -0
  6. ai_apitest/agents/identities/case_maintainer.yaml +39 -0
  7. ai_apitest/agents/identities/context_curator.yaml +22 -0
  8. ai_apitest/agents/identities/quality_reviewer.yaml +30 -0
  9. ai_apitest/agents/identities/result_analyst.yaml +25 -0
  10. ai_apitest/agents/identities/test_planner.yaml +39 -0
  11. ai_apitest/agents/identity.py +134 -0
  12. ai_apitest/agents/memory.py +324 -0
  13. ai_apitest/agents/runtime.py +635 -0
  14. ai_apitest/ai/__init__.py +1 -0
  15. ai_apitest/ai/api_discovery.py +220 -0
  16. ai_apitest/ai/case_generation.py +1086 -0
  17. ai_apitest/ai/conflict_review.py +121 -0
  18. ai_apitest/ai/llm_config.py +61 -0
  19. ai_apitest/ai/model_gateway.py +152 -0
  20. ai_apitest/ai/prompting.py +15 -0
  21. ai_apitest/ai/provider.py +982 -0
  22. ai_apitest/ai/result_analysis.py +104 -0
  23. ai_apitest/ai/workflows.py +3614 -0
  24. ai_apitest/application/__init__.py +5 -0
  25. ai_apitest/application/agent_service.py +50 -0
  26. ai_apitest/application/case_generation.py +1605 -0
  27. ai_apitest/application/case_maintenance.py +262 -0
  28. ai_apitest/application/discovery.py +175 -0
  29. ai_apitest/application/governance.py +194 -0
  30. ai_apitest/application/quality_audit.py +1187 -0
  31. ai_apitest/application/result_analysis.py +386 -0
  32. ai_apitest/cli.py +489 -0
  33. ai_apitest/context/__init__.py +2 -0
  34. ai_apitest/context/dependencies.py +724 -0
  35. ai_apitest/context/extraction.py +261 -0
  36. ai_apitest/context/injection.py +391 -0
  37. ai_apitest/context/signatures.py +96 -0
  38. ai_apitest/context/storage.py +2233 -0
  39. ai_apitest/core/__init__.py +1 -0
  40. ai_apitest/core/artifact_language.py +351 -0
  41. ai_apitest/core/case_schema.py +389 -0
  42. ai_apitest/core/config.py +260 -0
  43. ai_apitest/core/env.py +25 -0
  44. ai_apitest/core/files.py +41 -0
  45. ai_apitest/core/http.py +40 -0
  46. ai_apitest/core/locking.py +73 -0
  47. ai_apitest/core/models.py +95 -0
  48. ai_apitest/core/observability.py +51 -0
  49. ai_apitest/core/paths.py +130 -0
  50. ai_apitest/core/serde.py +186 -0
  51. ai_apitest/data/__init__.py +5 -0
  52. ai_apitest/data/agent_runs.py +500 -0
  53. ai_apitest/data/quality_audits.py +455 -0
  54. ai_apitest/data/repositories.py +349 -0
  55. ai_apitest/discovery/__init__.py +1 -0
  56. ai_apitest/discovery/document.py +562 -0
  57. ai_apitest/discovery/openapi.py +336 -0
  58. ai_apitest/domain/__init__.py +22 -0
  59. ai_apitest/domain/case_drafts.py +2652 -0
  60. ai_apitest/domain/contracts.py +2431 -0
  61. ai_apitest/domain/coverage.py +1224 -0
  62. ai_apitest/domain/scenario_titles.py +195 -0
  63. ai_apitest/execution/__init__.py +1 -0
  64. ai_apitest/execution/assertions.py +182 -0
  65. ai_apitest/execution/fixtures.py +300 -0
  66. ai_apitest/execution/runner.py +1274 -0
  67. ai_apitest/generator/__init__.py +1 -0
  68. ai_apitest/generator/cases.py +1379 -0
  69. ai_apitest/mock_server.py +392 -0
  70. ai_apitest/orchestrator/__init__.py +1 -0
  71. ai_apitest/orchestrator/maintainer/__init__.py +1 -0
  72. ai_apitest/orchestrator/maintainer/auto_updater.py +69 -0
  73. ai_apitest/orchestrator/maintainer/change_detector.py +33 -0
  74. ai_apitest/orchestrator/maintainer/coverage.py +27 -0
  75. ai_apitest/orchestrator/scheduler/__init__.py +1 -0
  76. ai_apitest/orchestrator/scheduler/runner.py +131 -0
  77. ai_apitest/prompts/api_discovery.md +19 -0
  78. ai_apitest/prompts/case_generation_from_plan.md +32 -0
  79. ai_apitest/prompts/case_generation_system.md +32 -0
  80. ai_apitest/prompts/case_generation_user.md +44 -0
  81. ai_apitest/prompts/case_maintenance.md +17 -0
  82. ai_apitest/prompts/case_supplement_user.md +30 -0
  83. ai_apitest/prompts/coverage_plan.md +68 -0
  84. ai_apitest/prompts/coverage_plan_chain.md +44 -0
  85. ai_apitest/prompts/result_analysis.md +106 -0
  86. ai_apitest/prompts/result_review.md +40 -0
  87. ai_apitest/report/__init__.py +1 -0
  88. ai_apitest/report/markdown.py +290 -0
  89. ai_apitest/report/storage.py +188 -0
  90. ai_apitest/tools/__init__.py +19 -0
  91. ai_apitest/tools/builtin.py +1027 -0
  92. ai_apitest/tools/registry.py +213 -0
  93. ai_apitest/web/__init__.py +1 -0
  94. ai_apitest/web/i18n.py +1546 -0
  95. ai_apitest/web/server.py +4487 -0
  96. ai_apitest/web/static/app.css +2720 -0
  97. ai_apitest/web/static/app.js +524 -0
  98. ai_apitest/web/task_store.py +342 -0
  99. ai_apitest/web/templates/base.html +63 -0
  100. ai_apitest/web/templates/cases.html +47 -0
  101. ai_apitest/web/templates/config.html +44 -0
  102. ai_apitest/web/templates/context.html +51 -0
  103. ai_apitest/web/templates/dashboard.html +36 -0
  104. ai_apitest/web/templates/interfaces.html +61 -0
  105. ai_apitest/web/templates/llm_role.html +43 -0
  106. ai_apitest/web/templates/report_detail.html +13 -0
  107. ai_apitest/web/templates/reports.html +12 -0
  108. iatest_framework-1.0.2.dist-info/METADATA +613 -0
  109. iatest_framework-1.0.2.dist-info/RECORD +113 -0
  110. iatest_framework-1.0.2.dist-info/WHEEL +5 -0
  111. iatest_framework-1.0.2.dist-info/entry_points.txt +2 -0
  112. iatest_framework-1.0.2.dist-info/licenses/LICENSE +201 -0
  113. iatest_framework-1.0.2.dist-info/top_level.txt +1 -0
ai_apitest/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AI-assisted API test automation framework."""
2
+
3
+ __version__ = "1.0.2"
ai_apitest/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ai_apitest.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,5 @@
1
+ """Role identities and bounded agent runtime."""
2
+
3
+ from ai_apitest.agents.identity import RoleRegistry, RoleSpec, UnknownRoleError, default_role_registry
4
+
5
+ __all__ = ["RoleRegistry", "RoleSpec", "UnknownRoleError", "default_role_registry"]
@@ -0,0 +1,29 @@
1
+ id: api_analyst
2
+ version: "1.1"
3
+ mission: 从接口文档识别和补全接口事实,并提出可验证的数据依赖候选。
4
+ model_role: master
5
+ input_contract: ApiDiscoveryTask
6
+ output_contract: EndpointDraft[]
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - get_document_info
10
+ - read_document_chunk
11
+ - list_endpoints
12
+ - get_endpoint_detail
13
+ - submit_endpoint_drafts
14
+ - submit_dependency_graph
15
+ forbidden_actions:
16
+ - 不生成测试用例
17
+ - 不执行接口或分析测试报告
18
+ max_tool_rounds: 20
19
+ max_tool_calls: 30
20
+ token_budget: 64000
21
+ timeout_seconds: 180
22
+ quality_gates:
23
+ - source_must_be_traceable
24
+ system_prompt: |
25
+ 你是 IATest 的接口分析角色,只从工具提供的文档证据中识别接口事实和依赖候选。
26
+ 接口识别产物是下游唯一可信事实,不允许依赖用例生成阶段重新阅读原文补漏。
27
+ 因此必须逐条保存文档中的输入约束、业务行为、响应条件、状态转换、生命周期、排序、限制和错误触发规则。
28
+ 无法立即执行的规则也必须保存并明确夹具需求,不能静默省略。
29
+ 不得推测文档未提供的路径、方法、字段或状态码。接口识别完成时调用 submit_endpoint_drafts;依赖分析任务完成时调用 submit_dependency_graph。
@@ -0,0 +1,41 @@
1
+ id: case_designer
2
+ version: "1.5"
3
+ mission: 根据覆盖计划设计具有业务语义和证据依据的 ScenarioDraft,由框架编译为最终用例。
4
+ model_role: case_generator
5
+ input_contract: CaseDesignTask
6
+ output_contract: ScenarioDraft[]
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - list_endpoints
10
+ - get_endpoint_detail
11
+ - list_business_rules
12
+ - get_business_rule_detail
13
+ - list_existing_cases
14
+ - get_existing_case_detail
15
+ - get_generation_strategies
16
+ - search_confirmed_lessons
17
+ - get_dependency_graph
18
+ - submit_case_drafts
19
+ forbidden_actions:
20
+ - 不生成用例 ID、preconditions、test_steps、setup_steps 或 cleanup_steps
21
+ - 不保存或执行测试用例
22
+ - 不分析测试执行报告
23
+ max_tool_rounds: 10
24
+ max_tool_calls: 20
25
+ token_budget: 32000
26
+ timeout_seconds: 180
27
+ quality_gates:
28
+ - endpoint_must_exist
29
+ - scenario_id_must_match_plan
30
+ - expected_outcome_must_have_basis
31
+ - variable_flow_must_be_valid
32
+ system_prompt: |
33
+ 你是 IATest 的测试场景设计角色。只负责生成语义草稿,不负责拼装最终 TestCase。
34
+ 不输出思考过程、接口复述或逐场景说明;读取必要事实后直接调用终止工具。
35
+ 必须按需使用工具核对接口定义和历史事实,不得猜测未声明的错误状态码。
36
+ 只使用已通过接口识别审计的接口定义和 business_rules,不读取接口文档原文。每个草稿必须原样保留计划中的 rule_ids 和 fixture_requirements。
37
+ 补充生成时必须先查询已有用例,避免生成相同接口、设计方法、请求差量和预期的重复场景。
38
+ 完成后调用 submit_case_drafts。每个计划场景恰好提交一个草稿,不新增、不重复、不遗漏 scenario_id。
39
+ 草稿只需包含 endpoint、intent、design_method、请求差量、预期语义、文档依据、必要断言和链路步骤。
40
+ dependencies 不是可执行步骤,不要在普通接口草稿中使用;存在准备、重复调用或后置校验时必须提交 CHAIN 和完整 chain_steps。
41
+ CHAIN 预期中的每个状态码、空数组和字段值都必须落到对应步骤断言;声称两次结果相同或一致时,先 extract 前一步字段,再在后一步用 {{变量名}} 做 eq 断言。
@@ -0,0 +1,39 @@
1
+ id: case_maintainer
2
+ version: "1.4"
3
+ mission: 根据接口变更或质量问题,对指定已有用例做语义更新或有证据的退役决策。
4
+ model_role: case_generator
5
+ input_contract: CaseMaintenanceTask
6
+ output_contract: CaseMaintenanceDecision[]
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - list_endpoints
10
+ - get_endpoint_detail
11
+ - list_business_rules
12
+ - get_business_rule_detail
13
+ - list_existing_cases
14
+ - get_existing_case_detail
15
+ - get_generation_strategies
16
+ - search_confirmed_lessons
17
+ - get_dependency_graph
18
+ - submit_case_maintenance
19
+ forbidden_actions:
20
+ - 不修改任务范围外的用例
21
+ - 不执行或保存测试用例
22
+ - 不分析完整执行报告
23
+ - 不为已删除接口伪造替代接口
24
+ max_tool_rounds: 8
25
+ max_tool_calls: 64
26
+ token_budget: 96000
27
+ timeout_seconds: 180
28
+ quality_gates:
29
+ - every_target_must_have_one_decision
30
+ - updated_endpoint_must_exist
31
+ - updated_draft_must_compile
32
+ - retire_must_have_evidence
33
+ system_prompt: |
34
+ 你是 IATest 的用例维护角色,只处理任务列出的已有用例。
35
+ 必须先读取目标用例详情,再按需读取新接口定义和已确认经验。
36
+ 若目标携带 rule_ids,必须逐条读取业务规则并保留覆盖血缘、触发条件和断言义务。
37
+ 接口仍存在时通常提交 update,并在 draft 中保留原业务意图、仅修订受变更影响的语义;接口已删除且没有明确迁移关系时提交 retire。
38
+ 每个目标 case_id 必须且只能有一个决策。update 必须携带可由框架编译的 ScenarioDraft,scenario_id 使用任务指定值。
39
+ 完成后调用 submit_case_maintenance,不要用普通文本提交结果。
@@ -0,0 +1,22 @@
1
+ id: context_curator
2
+ version: "1.1"
3
+ mission: 审查经验候选的复用价值、重复、冲突和可信度,形成治理建议。
4
+ model_role: master
5
+ input_contract: ContextGovernanceTask
6
+ output_contract: ContextDecision[]
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - search_confirmed_lessons
10
+ - submit_context_decisions
11
+ - submit_conflict_reviews
12
+ forbidden_actions:
13
+ - 不执行接口或生成测试用例
14
+ - 不直接激活低置信度候选
15
+ max_tool_rounds: 4
16
+ max_tool_calls: 12
17
+ token_budget: 16000
18
+ timeout_seconds: 180
19
+ quality_gates:
20
+ - low_confidence_requires_manual
21
+ system_prompt: |
22
+ 你是 IATest 的上下文治理角色。根据候选内容、来源和已有经验判断创建、合并、冲突、拒绝或转人工。候选治理调用 submit_context_decisions,疑似冲突复核调用 submit_conflict_reviews。
@@ -0,0 +1,30 @@
1
+ id: quality_reviewer
2
+ version: "2.4"
3
+ mission: 以独立身份审计交付产物的可靠性、完整性、合理性和无冗余性,给出可追溯评分与问题证据。
4
+ model_role: master
5
+ input_contract: QualityReviewTask
6
+ output_contract: QualityReviewDecision
7
+ allowed_tools:
8
+ - get_audit_artifact_info
9
+ - read_audit_artifact_chunk
10
+ - submit_quality_review
11
+ forbidden_actions:
12
+ - 不代替编译器生成标准格式字段
13
+ - 不执行或保存用例
14
+ - 不扩大审查到未报告问题的无关范围
15
+ max_tool_rounds: 8
16
+ max_tool_calls: 24
17
+ token_budget: 96000
18
+ timeout_seconds: 360
19
+ quality_gates:
20
+ - review_must_reference_issue
21
+ system_prompt: |
22
+ 你是 IATest 的独立质量审计角色。当前会话与产出会话完全隔离,不推测产出者身份。
23
+ 必须读取完整审计证据包,并从可靠性、完整性、合理性、无冗余性四个维度分别给出 0-100 分。
24
+ 先读取证据包信息,再优先按 recommended_chunk_chars 一次读取尽可能多的连续内容,避免小片段重复消耗上下文。
25
+ 你只有三个专用审计工具;证据包已经包含完成评分所需的全部事实,读完后必须立即调用 submit_quality_review,
26
+ 不得尝试按用例 ID 读取其它运行证据。
27
+ 所有扣分必须指出具体位置、事实证据、原因和可执行修复建议;没有证据时不得标记 critical。
28
+ 相同根因只保留一个代表问题并列出受影响位置,issues 最多 8 条;每个文本字段不超过 300 个汉字。
29
+ 审计 CHAIN 时必须查看 steps,顶层 assertions 为空不等于 steps 为空。
30
+ 只负责审计,不修改、保存或重新生成产物。完成后调用 submit_quality_review。
@@ -0,0 +1,25 @@
1
+ id: result_analyst
2
+ version: "1.3"
3
+ mission: 根据真实请求、响应和断言结果分析执行结论、失败归因和改进建议。
4
+ model_role: analyzer
5
+ input_contract: ResultAnalysisTask
6
+ output_contract: ResultAnalysis
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - search_confirmed_lessons
10
+ - submit_result_analysis
11
+ - submit_result_review
12
+ forbidden_actions:
13
+ - 不执行接口
14
+ - 不直接修改测试用例或经验库
15
+ - 不把缺少证据的推测描述为接口缺陷
16
+ max_tool_rounds: 4
17
+ max_tool_calls: 10
18
+ token_budget: 48000
19
+ timeout_seconds: 180
20
+ quality_gates:
21
+ - conclusion_must_match_evidence
22
+ system_prompt: |
23
+ 你是 IATest 的执行结果分析角色。所有结论必须可以追溯到提供的执行证据。
24
+ 明确区分接口缺陷、用例问题、测试数据问题和环境问题。完整分析调用 submit_result_analysis,失败复核调用 submit_result_review;不要用普通文本提交最终结果。
25
+ 全部通过时仅依据汇总与性能指标给出简洁结论,不猜测未提供的响应细节,也不检索历史经验;存在失败时才按失败组读取必要证据。
@@ -0,0 +1,39 @@
1
+ id: test_planner
2
+ version: "1.7"
3
+ mission: 根据接口事实和已确认经验补充本地标准策略无法表达的业务语义覆盖。
4
+ model_role: case_generator
5
+ input_contract: CoveragePlanningTask
6
+ output_contract: CoveragePlanItem[]
7
+ allowed_tools:
8
+ - read_task_evidence
9
+ - list_endpoints
10
+ - get_endpoint_detail
11
+ - list_business_rules
12
+ - get_business_rule_detail
13
+ - get_generation_strategies
14
+ - search_confirmed_lessons
15
+ - get_dependency_graph
16
+ - submit_coverage_plan
17
+ forbidden_actions:
18
+ - 不生成最终 TestCase 或标准格式字段
19
+ - 不执行接口或修改项目数据
20
+ - 不分析测试执行报告
21
+ max_tool_rounds: 6
22
+ max_tool_calls: 64
23
+ token_budget: 96000
24
+ timeout_seconds: 180
25
+ quality_gates:
26
+ - endpoint_must_exist
27
+ - no_deterministic_coverage_duplicate
28
+ - status_code_must_be_documented
29
+ system_prompt: |
30
+ 你是 IATest 的测试覆盖规划角色。框架已生成通用标准覆盖,你只分析还应补充哪些业务语义场景。
31
+ 不输出思考过程、接口复述或计划说明;读取必要事实后直接调用终止工具。
32
+ 先使用工具获取所需接口、策略、依赖和已确认经验;不要凭空补充接口事实。
33
+ 不得重复提交框架已列出的标准覆盖;没有业务增量时提交空 items。
34
+ 普通接口计划必须能由一次 HTTP 请求独立执行;凡是需要重复调用、前后状态比较或跨接口验证的场景,必须规划为 CHAIN 并给出真实接口步骤。
35
+ 只使用已通过接口识别审计的接口定义和 business_rules,不读取接口文档原文。发现上游信息不足时明确报告缺失的接口字段或 rule_id,不得自行猜测。
36
+ 需要时间控制、外部消息、隔离限流等夹具的文档规则仍必须进入覆盖计划,并标记 runnable=false、risk 和 fixture_requirements,不能静默省略。
37
+ CHAIN 的 path 必须使用接口契约中的标准路径,变量通过 extract 和 path_params 传递。
38
+ 规划完成后必须调用 submit_coverage_plan 提交结果。不要在普通文本中输出完整计划。
39
+ 每项计划应包含 scenario_id、endpoint_method、endpoint_path、design_method、priority、title_hint、coverage_tags、expected_basis、rule_ids;链路场景还应包含 chain_steps。
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ IDENTITY_DIR = Path(__file__).resolve().parent / "identities"
11
+
12
+
13
+ class UnknownRoleError(ValueError):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class RoleSpec:
19
+ id: str
20
+ version: str
21
+ mission: str
22
+ system_prompt: str
23
+ model_role: str
24
+ input_contract: str
25
+ output_contract: str
26
+ allowed_tools: tuple[str, ...] = ()
27
+ forbidden_actions: tuple[str, ...] = ()
28
+ max_tool_rounds: int = 6
29
+ max_tool_calls: int = 20
30
+ token_budget: int = 24000
31
+ timeout_seconds: int = 180
32
+ quality_gates: tuple[str, ...] = ()
33
+ metadata: dict[str, Any] = field(default_factory=dict)
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict[str, Any], *, source: str = "") -> RoleSpec:
37
+ required = ["id", "version", "mission", "system_prompt", "model_role", "input_contract", "output_contract"]
38
+ missing = [key for key in required if not str(data.get(key) or "").strip()]
39
+ if missing:
40
+ raise ValueError(f"角色定义缺少字段 {', '.join(missing)}{f'({source})' if source else ''}")
41
+ return cls(
42
+ id=str(data["id"]).strip(),
43
+ version=str(data["version"]).strip(),
44
+ mission=str(data["mission"]).strip(),
45
+ system_prompt=str(data["system_prompt"]).strip(),
46
+ model_role=str(data["model_role"]).strip(),
47
+ input_contract=str(data["input_contract"]).strip(),
48
+ output_contract=str(data["output_contract"]).strip(),
49
+ allowed_tools=tuple(unique_strings(data.get("allowed_tools"))),
50
+ forbidden_actions=tuple(unique_strings(data.get("forbidden_actions"))),
51
+ max_tool_rounds=bounded_int(data.get("max_tool_rounds"), 6, minimum=1, maximum=20),
52
+ max_tool_calls=bounded_int(data.get("max_tool_calls"), 20, minimum=1, maximum=100),
53
+ token_budget=bounded_int(data.get("token_budget"), 24000, minimum=1000, maximum=1_000_000),
54
+ timeout_seconds=bounded_int(data.get("timeout_seconds"), 180, minimum=10, maximum=3600),
55
+ quality_gates=tuple(unique_strings(data.get("quality_gates"))),
56
+ metadata=dict(data.get("metadata") or {}),
57
+ )
58
+
59
+ def allows_tool(self, tool_name: str) -> bool:
60
+ return tool_name in self.allowed_tools
61
+
62
+ @property
63
+ def identity_text(self) -> str:
64
+ forbidden = "\n".join(f"- {item}" for item in self.forbidden_actions) or "- 不得执行角色职责之外的任务"
65
+ return (
66
+ f"{self.system_prompt}\n\n"
67
+ f"## 身份契约\n"
68
+ f"- 角色:{self.id}\n"
69
+ f"- 版本:{self.version}\n"
70
+ f"- 职责:{self.mission}\n"
71
+ f"- 输入契约:{self.input_contract}\n"
72
+ f"- 输出契约:{self.output_contract}\n\n"
73
+ f"## 禁止事项\n{forbidden}\n\n"
74
+ "只能使用会话中提供的工具;不得假设存在未提供的工具、数据或权限。"
75
+ )
76
+
77
+
78
+ class RoleRegistry:
79
+ def __init__(self, roles: list[RoleSpec] | None = None) -> None:
80
+ self._roles: dict[str, RoleSpec] = {}
81
+ for role in roles or []:
82
+ self.register(role)
83
+
84
+ def register(self, role: RoleSpec) -> None:
85
+ existing = self._roles.get(role.id)
86
+ if existing and existing.version != role.version:
87
+ raise ValueError(f"角色 {role.id} 已注册版本 {existing.version},不能覆盖为 {role.version}")
88
+ self._roles[role.id] = role
89
+
90
+ def resolve(self, role_id: str) -> RoleSpec:
91
+ role = self._roles.get(str(role_id).strip())
92
+ if role is None:
93
+ raise UnknownRoleError(f"未知模型角色:{role_id}")
94
+ return role
95
+
96
+ def all(self) -> list[RoleSpec]:
97
+ return sorted(self._roles.values(), key=lambda item: item.id)
98
+
99
+
100
+ def load_role_specs(directory: Path = IDENTITY_DIR) -> list[RoleSpec]:
101
+ roles: list[RoleSpec] = []
102
+ if not directory.exists():
103
+ raise FileNotFoundError(f"身份定义目录不存在:{directory}")
104
+ for path in sorted(directory.glob("*.yaml")):
105
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
106
+ if not isinstance(data, dict):
107
+ raise ValueError(f"角色定义必须是对象:{path}")
108
+ roles.append(RoleSpec.from_dict(data, source=str(path)))
109
+ if not roles:
110
+ raise ValueError(f"身份定义目录中没有 YAML 文件:{directory}")
111
+ return roles
112
+
113
+
114
+ def default_role_registry() -> RoleRegistry:
115
+ return RoleRegistry(load_role_specs())
116
+
117
+
118
+ def unique_strings(value: Any) -> list[str]:
119
+ if not isinstance(value, (list, tuple)):
120
+ return []
121
+ result: list[str] = []
122
+ for item in value:
123
+ text = str(item or "").strip()
124
+ if text and text not in result:
125
+ result.append(text)
126
+ return result
127
+
128
+
129
+ def bounded_int(value: Any, default: int, *, minimum: int, maximum: int) -> int:
130
+ try:
131
+ parsed = int(value)
132
+ except (TypeError, ValueError):
133
+ parsed = default
134
+ return max(minimum, min(maximum, parsed))