open-reflection-protocol 0.3.0__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.
orp/reflect.py ADDED
@@ -0,0 +1,97 @@
1
+ """Reflection Analyzer — 诊断、替代策略、Challenger"""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from orp.schema import (
6
+ ExperienceRecord, TimelineEvent, EventKind, ReflectionAnalysis,
7
+ )
8
+ from orp.storage import ORPStorage
9
+
10
+
11
+ class ReflectionAnalyzer:
12
+ """反思分析 — 输出结构化候选,不直接修改 Agent"""
13
+
14
+ def analyze(self, record: ExperienceRecord) -> ReflectionAnalysis:
15
+ """对 ExperienceRecord 执行反思分析"""
16
+ diagnosis = self._diagnose(record)
17
+ alternatives = self._suggest_alternatives(record)
18
+ limitations = self._find_limitations(record)
19
+ return ReflectionAnalysis(
20
+ diagnosis=diagnosis,
21
+ alternatives=alternatives,
22
+ limitations=limitations,
23
+ )
24
+
25
+ def _diagnose(self, record: ExperienceRecord) -> Optional[str]:
26
+ """从失败的运行中生成诊断"""
27
+ outcome_events = [
28
+ e for e in record.timeline
29
+ if e.kind == EventKind.OUTCOME
30
+ ]
31
+ error_events = [
32
+ e for e in record.timeline
33
+ if e.kind == EventKind.OBSERVATION
34
+ and any(w in e.content.lower() for w in ["error", "fail", "exception", "traceback"])
35
+ ]
36
+ if outcome_events:
37
+ return f"Outcome: {outcome_events[-1].content}"
38
+ if error_events:
39
+ return f"Detected error: {error_events[-1].content[:200]}"
40
+ if record.outcome.status == "failed":
41
+ return "Task failed — review timeline for root cause"
42
+ return None
43
+
44
+ def _suggest_alternatives(self, record: ExperienceRecord) -> list[str]:
45
+ """基于失败的运行提出替代策略"""
46
+ suggestions = []
47
+ has_test = any(
48
+ "test" in e.content.lower() or "pytest" in e.content.lower()
49
+ for e in record.timeline
50
+ )
51
+ has_diff = any(
52
+ "git diff" in e.content.lower() or "diff" in e.content.lower()
53
+ for e in record.timeline
54
+ )
55
+ if record.outcome.status == "failed":
56
+ if not has_test:
57
+ suggestions.append("Run tests first to confirm the failure")
58
+ if not has_diff:
59
+ suggestions.append("Check git diff to understand what changed")
60
+ return suggestions
61
+
62
+ def _find_limitations(self, record: ExperienceRecord) -> list[str]:
63
+ """识别这次运行的局限性"""
64
+ limits = []
65
+ if not record.task.get("input_ref"):
66
+ limits.append("No input reference recorded — cannot reproduce exact input")
67
+ claim_count = sum(1 for e in record.timeline if e.kind == EventKind.CLAIM)
68
+ evidence_count = sum(len(e.evidence_refs) for e in record.timeline)
69
+ if claim_count > evidence_count:
70
+ limits.append(f"More claims ({claim_count}) than evidence refs ({evidence_count})")
71
+ return limits
72
+
73
+
74
+ class Challenger:
75
+ """Challenger — 质疑未经证明的声明
76
+
77
+ 自动查找 ExperienceRecord 中的 claim 及其证据支持情况。
78
+ """
79
+
80
+ def challenge(self, record: ExperienceRecord) -> list[dict[str, Any]]:
81
+ """找出所有未经充分支持的声明"""
82
+ challenged: list[dict[str, Any]] = []
83
+ for evt in record.timeline:
84
+ if evt.kind == EventKind.CLAIM:
85
+ if not evt.evidence_refs:
86
+ challenged.append({
87
+ "event_id": evt.id,
88
+ "content": evt.content[:100],
89
+ "issue": "No evidence references provided",
90
+ })
91
+ elif len(evt.evidence_refs) < 2:
92
+ challenged.append({
93
+ "event_id": evt.id,
94
+ "content": evt.content[:100],
95
+ "issue": "Only 1 evidence ref — may be insufficient",
96
+ })
97
+ return challenged
orp/replay.py ADDED
@@ -0,0 +1,108 @@
1
+ """Counterfactual Replay — 隔离环境回放替代策略"""
2
+
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ from typing import Any, Optional
7
+
8
+ from orp.schema import CounterfactualReplay
9
+
10
+
11
+ class CounterfactualReplayer:
12
+ """反事实回放 — 在隔离环境中比较原始策略与替代策略"""
13
+
14
+ def replay(self, experience_id: str, original: str, alternative: str,
15
+ workdir: Optional[str] = None) -> CounterfactualReplay:
16
+ """尝试在隔离环境中回放替代策略
17
+
18
+ 返回 CounterfactualReplay,其中 result.status 为:
19
+ - improved: 替代策略结果更好
20
+ - equivalent: 结果相当
21
+ - worse: 替代策略更差
22
+ - predicted: 无法实际回放,只能输出预测
23
+ """
24
+ isolation = self._create_isolation(workdir)
25
+ if not isolation:
26
+ # 无法创建隔离环境,只能输出预测
27
+ return CounterfactualReplay(
28
+ experience_id=experience_id,
29
+ original_strategy=original,
30
+ alternative_strategy=alternative,
31
+ verification_mode="predicted",
32
+ result={"status": "predicted", "note": "Could not create isolation environment"},
33
+ )
34
+
35
+ try:
36
+ # 运行替代策略
37
+ start_cmd = alternative.split()
38
+ if not start_cmd:
39
+ return CounterfactualReplay(
40
+ experience_id=experience_id,
41
+ original_strategy=original,
42
+ alternative_strategy=alternative,
43
+ verification_mode="sandbox_replay",
44
+ result={"status": "predicted", "note": "Empty alternative strategy"},
45
+ )
46
+
47
+ result = subprocess.run(
48
+ start_cmd,
49
+ capture_output=True, text=True,
50
+ cwd=isolation, timeout=120,
51
+ )
52
+ success = result.returncode == 0
53
+
54
+ return CounterfactualReplay(
55
+ experience_id=experience_id,
56
+ original_strategy=original,
57
+ alternative_strategy=alternative,
58
+ verification_mode="sandbox_replay",
59
+ result={
60
+ "status": "improved" if success else "worse",
61
+ "exit_code": result.returncode,
62
+ "duration": "completed",
63
+ },
64
+ )
65
+ except subprocess.TimeoutExpired:
66
+ return CounterfactualReplay(
67
+ experience_id=experience_id,
68
+ original_strategy=original,
69
+ alternative_strategy=alternative,
70
+ verification_mode="sandbox_replay",
71
+ result={"status": "worse", "error": "timed out"},
72
+ )
73
+ except FileNotFoundError:
74
+ return CounterfactualReplay(
75
+ experience_id=experience_id,
76
+ original_strategy=original,
77
+ alternative_strategy=alternative,
78
+ verification_mode="predicted",
79
+ result={"status": "predicted", "error": "command not found"},
80
+ )
81
+ finally:
82
+ self._cleanup_isolation(isolation)
83
+
84
+ def _create_isolation(self, workdir: Optional[str] = None) -> Optional[str]:
85
+ try:
86
+ tmp = tempfile.mkdtemp(prefix="orp_replay_")
87
+ if workdir and os.path.isdir(workdir):
88
+ # 复制工作目录内容(浅层)
89
+ for item in os.listdir(workdir):
90
+ src = os.path.join(workdir, item)
91
+ dst = os.path.join(tmp, item)
92
+ if os.path.isfile(src):
93
+ try:
94
+ with open(src, 'rb') as fsrc:
95
+ with open(dst, 'wb') as fdst:
96
+ fdst.write(fsrc.read())
97
+ except (PermissionError, OSError):
98
+ pass
99
+ return tmp
100
+ except Exception:
101
+ return None
102
+
103
+ def _cleanup_isolation(self, path: str) -> None:
104
+ try:
105
+ import shutil
106
+ shutil.rmtree(path, ignore_errors=True)
107
+ except Exception:
108
+ pass
orp/rollback.py ADDED
@@ -0,0 +1,82 @@
1
+ """Rollback Manager — Lesson 降级、撤回与恢复"""
2
+
3
+ from typing import Optional
4
+
5
+ from orp.schema import (
6
+ Lesson, LessonRollback, LessonStatus,
7
+ )
8
+ from orp.storage import ORPStorage
9
+
10
+
11
+ class RollbackManager:
12
+ """回滚管理 — 坏 Lesson 的审计撤回"""
13
+
14
+ def __init__(self, storage: Optional[ORPStorage] = None):
15
+ self._storage = storage or ORPStorage()
16
+
17
+ def rollback(self, lesson_id: str, reason: str,
18
+ new_status: LessonStatus = LessonStatus.UNDER_REVIEW,
19
+ replacement_id: Optional[str] = None) -> Optional[LessonRollback]:
20
+ """撤回一条 Lesson
21
+
22
+ 默认进入 under_review 而非直接 rejected,保留复审机会。
23
+ """
24
+ lesson = self._storage.get_lesson(lesson_id)
25
+ if not lesson:
26
+ return None
27
+
28
+ previous = lesson.status
29
+ rollback = LessonRollback(
30
+ lesson_id=lesson_id,
31
+ reason=reason,
32
+ previous_status=previous,
33
+ new_status=new_status,
34
+ replacement_lesson_id=replacement_id,
35
+ affected_deliveries=[
36
+ d.delivery_id
37
+ for d in self._storage.get_deliveries_for_lesson(lesson_id)
38
+ ],
39
+ )
40
+
41
+ # 更新 Lesson 状态
42
+ self._storage.update_lesson_status(lesson_id, new_status)
43
+ # 保存回滚记录
44
+ self._storage.save_rollback(rollback)
45
+
46
+ # 如果是 POLICY_FILE 交付的,尝试从 AGENTS.md 移除
47
+ if previous == LessonStatus.ACTIVE:
48
+ self._cleanup_policy_file(lesson_id)
49
+
50
+ return rollback
51
+
52
+ def restore(self, lesson_id: str) -> bool:
53
+ """将 under_review 的 Lesson 恢复到 active"""
54
+ lesson = self._storage.get_lesson(lesson_id)
55
+ if not lesson or lesson.status != LessonStatus.UNDER_REVIEW:
56
+ return False
57
+ self._storage.update_lesson_status(lesson_id, LessonStatus.ACTIVE)
58
+ return True
59
+
60
+ def _cleanup_policy_file(self, lesson_id: str) -> None:
61
+ """从 AGENTS.md 中移除指定 Lesson 相关的区块"""
62
+ import os
63
+ try:
64
+ agents_path = os.path.join(os.getcwd(), "AGENTS.md")
65
+ if not os.path.exists(agents_path):
66
+ return
67
+ with open(agents_path, "r") as f:
68
+ content = f.read()
69
+ start_marker = f"<!-- ORP Lesson: {lesson_id} -->"
70
+ end_marker = "<!-- END ORP Lesson -->"
71
+ start = content.find(start_marker)
72
+ if start == -1:
73
+ return
74
+ end = content.find(end_marker, start)
75
+ if end == -1:
76
+ return
77
+ end += len(end_marker)
78
+ new_content = content[:start] + content[end:]
79
+ with open(agents_path, "w") as f:
80
+ f.write(new_content)
81
+ except (IOError, PermissionError, FileNotFoundError):
82
+ pass
orp/schema.py ADDED
@@ -0,0 +1,303 @@
1
+ # ORP Core Schema v0.3
2
+ # 代码即规范 — 此文件中的所有 Pydantic 模型构成 ORP 协议的官方定义
3
+ #
4
+ # 设计原则:
5
+ # 1. Evidence first: 结论必须引用证据,无证据的标记为 claim
6
+ # 2. 区分事实与声明: observation/action 是事实,claim/decision 是声明
7
+ # 3. 可执行: 反思优先编译为 Lesson/Eval/Guardrail
8
+ # 4. Outcome based: 经验价值由后续任务结果决定
9
+ # 5. 基于 OpenTelemetry: 不替代 tracing,而是扩展它
10
+
11
+ from __future__ import annotations
12
+ from datetime import datetime, timezone
13
+ from enum import Enum
14
+ from typing import Any, Optional
15
+ from uuid import UUID, uuid4
16
+ from pydantic import BaseModel, Field, field_validator, model_validator
17
+
18
+
19
+ # ─── Helpers ─────────────────────────────────────────────────
20
+
21
+ def _now() -> datetime:
22
+ return datetime.now(timezone.utc)
23
+
24
+ def _uuid() -> UUID:
25
+ return uuid4()
26
+
27
+
28
+ # ─── Enums ───────────────────────────────────────────────────
29
+
30
+ class EventKind(str, Enum):
31
+ """TimelineEvent 类型 — 必须区分可观察事实与 Agent 声明"""
32
+ OBSERVATION = "observation" # 工具/环境/外部系统产生的可观察结果
33
+ ACTION = "action" # Agent 或用户执行的动作
34
+ CLAIM = "claim" # Agent 对原因/状态/结果的声明
35
+ DECISION = "decision" # Agent 在多个方案间做出的选择
36
+ FEEDBACK = "feedback" # 人工/规则/模型/用户评价
37
+ OUTCOME = "outcome" # 测试/验收/生产指标等结果
38
+
39
+
40
+ class TrustLevel(str, Enum):
41
+ """可信等级 — 不使用缺乏校准的单一评分"""
42
+ ASSERTED = "asserted" # 未经外部证据支持的声明
43
+ OBSERVED = "observed" # 被工具/环境/trace 观察到
44
+ REPRODUCED = "reproduced" # 独立重跑中复现
45
+ EXTERNALLY_VERIFIED = "externally_verified" # 被规则/测试/系统验证
46
+ HUMAN_CONFIRMED = "human_confirmed" # 被授权人工确认
47
+ REGRESSION_GUARDED = "regression_guarded" # 已形成持续运行的回归 Eval
48
+
49
+
50
+ class LessonStatus(str, Enum):
51
+ CANDIDATE = "candidate" # 由单次经验生成,尚未验证
52
+ ACTIVE = "active" # 通过外部验证,可被检索
53
+ UNDER_REVIEW = "under_review" # 发现冲突/负面效果,暂停默认交付
54
+ DEPRECATED = "deprecated" # 效果不佳/冲突/过期
55
+ REJECTED = "rejected" # 被证明错误
56
+
57
+
58
+ class DeliveryStrategy(str, Enum):
59
+ MCP_TOOL = "mcp_tool" # Agent 主动调用 MCP 工具
60
+ PROMPT_CONTEXT = "prompt_context" # 运行时注入系统/任务上下文
61
+ POLICY_FILE = "policy_file" # 写入 AGENTS.md 等策略文件
62
+ RUNTIME_HOOK = "runtime_hook" # 高风险动作前条件式注入
63
+
64
+
65
+ class FeedbackSourceType(str, Enum):
66
+ HUMAN = "human"
67
+ DETERMINISTIC = "deterministic"
68
+ LLM_JUDGE = "llm_judge"
69
+ USER = "user"
70
+ PRODUCTION_METRIC = "production_metric"
71
+
72
+
73
+ class EvaluationMethod(str, Enum):
74
+ DESCRIPTIVE = "descriptive" # 仅记录,不声称因果
75
+ MATCHED_BASELINE = "matched_baseline" # 与相似任务基线比较
76
+ RANDOMIZED = "randomized" # A/B 实验
77
+ CAUSAL_MODEL = "causal_model" # 贝叶斯分层等因果方法
78
+
79
+
80
+ class TrainingFormat(str, Enum):
81
+ SFT_EXAMPLE = "sft_example"
82
+ PREFERENCE_PAIR = "preference_pair"
83
+ CRITIQUE_REVISION = "critique_revision"
84
+ NEGATIVE_EXAMPLE = "negative_example"
85
+
86
+
87
+ class TrainingStatus(str, Enum):
88
+ CANDIDATE = "candidate"
89
+ APPROVED = "approved"
90
+ REJECTED = "rejected"
91
+
92
+
93
+ # ─── Core Objects ────────────────────────────────────────────
94
+
95
+ class EvidenceRef(BaseModel):
96
+ """证据引用 — 必须可定位、可校验"""
97
+ evidence_id: str
98
+ kind: str = Field(default="tool_output")
99
+ uri: Optional[str] = None
100
+ digest: Optional[str] = None
101
+ created_at: datetime = Field(default_factory=_now)
102
+ redaction: Optional[dict[str, Any]] = None
103
+
104
+
105
+ class Feedback(BaseModel):
106
+ """外部评价 — 必须记录来源"""
107
+ target_ref: str
108
+ source_type: FeedbackSourceType
109
+ source_id: str
110
+ verdict: str
111
+ explanation: Optional[str] = None
112
+ evidence_refs: list[str] = Field(default_factory=list)
113
+
114
+
115
+ class TimelineEvent(BaseModel):
116
+ """时间线事件 — 推理/操作/观察序列中的一个原子项"""
117
+ id: str = Field(default_factory=lambda: f"evt_{uuid4().hex[:8]}")
118
+ kind: EventKind
119
+ source: str = Field(default="agent") # agent | tool | human | system
120
+ content: str
121
+ evidence_refs: list[str] = Field(default_factory=list)
122
+ parent_event: Optional[str] = None
123
+ timestamp: datetime = Field(default_factory=_now)
124
+
125
+ @field_validator("content")
126
+ @classmethod
127
+ def content_not_empty(cls, v: str) -> str:
128
+ if not v.strip():
129
+ raise ValueError("Event content cannot be empty")
130
+ return v
131
+
132
+
133
+ class Outcome(BaseModel):
134
+ """运行结果 — 基于客观信号"""
135
+ status: str = Field(default="unknown") # success | failed | partial | unknown
136
+ objective_signals: list[dict[str, Any]] = Field(default_factory=list)
137
+
138
+
139
+ class ReflectionAnalysis(BaseModel):
140
+ """反思分析 — Agent 或 Challenger 对运行的结构化复盘"""
141
+ diagnosis: Optional[str] = None
142
+ alternatives: list[str] = Field(default_factory=list)
143
+ limitations: list[str] = Field(default_factory=list)
144
+
145
+
146
+ class ExperienceRecord(BaseModel):
147
+ """经验记录 — 一次 Agent 运行及其复盘结果"""
148
+ orp_version: str = Field(default="0.3")
149
+ experience_id: str = Field(default_factory=lambda: f"exp_{uuid4().hex[:12]}")
150
+ trace_ref: Optional[str] = None
151
+ agent: dict[str, Any] = Field(default_factory=lambda: {"id": "unknown", "version": "", "model": ""})
152
+ task: dict[str, Any] = Field(default_factory=lambda: {"goal": "", "domain": "", "input_ref": ""})
153
+ timeline: list[TimelineEvent] = Field(default_factory=list)
154
+ outcome: Outcome = Field(default_factory=Outcome)
155
+ reflection: Optional[ReflectionAnalysis] = None
156
+ artifacts: dict[str, list[str]] = Field(default_factory=lambda: {"lessons": [], "evals": [], "guardrails": []})
157
+ feedback: list[Feedback] = Field(default_factory=list)
158
+ created_at: datetime = Field(default_factory=_now)
159
+
160
+ @field_validator("timeline")
161
+ @classmethod
162
+ def timeline_not_empty(cls, v: list[TimelineEvent]) -> list[TimelineEvent]:
163
+ if not v:
164
+ raise ValueError("Timeline must have at least one event")
165
+ return v
166
+
167
+
168
+ class Lesson(BaseModel):
169
+ """课程/经验 — 可在未来任务中检索的条件化经验"""
170
+ lesson_id: str = Field(default_factory=lambda: f"lesson_{uuid4().hex[:12]}")
171
+ trigger: dict[str, Any] = Field(default_factory=lambda: {"domain": "", "conditions": []})
172
+ recommendation: str
173
+ provenance: dict[str, Any] = Field(default_factory=lambda: {"experience_ids": [], "evals": []})
174
+ scope: dict[str, Any] = Field(default_factory=lambda: {
175
+ "task_domains": [], "frameworks": [], "agent_versions": []
176
+ })
177
+ relationships: dict[str, list[str]] = Field(default_factory=lambda: {
178
+ "conflicts_with": [], "supersedes": [], "superseded_by": []
179
+ })
180
+ validation: dict[str, Any] = Field(default_factory=lambda: {"level": "asserted", "evidence_refs": []})
181
+ metrics: dict[str, Any] = Field(default_factory=lambda: {
182
+ "retrieved": 0, "delivered": 0, "acknowledged": 0, "applied": 0,
183
+ "successful_after_apply": 0, "estimated_effect": None
184
+ })
185
+ status: LessonStatus = LessonStatus.CANDIDATE
186
+ expires_at: Optional[datetime] = None
187
+ created_at: datetime = Field(default_factory=_now)
188
+ updated_at: datetime = Field(default_factory=_now)
189
+
190
+
191
+ class EvalArtifact(BaseModel):
192
+ """评估工件 — 将失败转换为可重复执行的评估"""
193
+ eval_id: str = Field(default_factory=lambda: f"eval_{uuid4().hex[:12]}")
194
+ origin_experience: str
195
+ runner: str = Field(default="pytest")
196
+ command: str
197
+ expected: dict[str, Any] = Field(default_factory=lambda: {"exit_code": 0})
198
+ generated_by: str = Field(default="agent")
199
+ review: Optional[dict[str, Any]] = None
200
+ last_result: Optional[dict[str, Any]] = None
201
+ created_at: datetime = Field(default_factory=_now)
202
+
203
+
204
+ class CounterfactualReplay(BaseModel):
205
+ """反事实回放 — 记录替代策略是否得到了验证"""
206
+ replay_id: str = Field(default_factory=lambda: f"replay_{uuid4().hex[:12]}")
207
+ experience_id: str
208
+ original_strategy: str
209
+ alternative_strategy: str
210
+ verification_mode: str = Field(default="sandbox_replay") # predicted | sandbox_replay | production
211
+ result: dict[str, Any] = Field(default_factory=lambda: {
212
+ "status": "unknown", "objective_delta": {}
213
+ })
214
+ created_at: datetime = Field(default_factory=_now)
215
+
216
+
217
+ class LessonDelivery(BaseModel):
218
+ """Lesson 交付 — 记录 Lesson 如何进入 Agent 上下文及是否被采纳"""
219
+ delivery_id: str = Field(default_factory=lambda: f"delivery_{uuid4().hex[:12]}")
220
+ lesson_id: str
221
+ experience_id: str
222
+ strategy: DeliveryStrategy
223
+ delivered_at: datetime = Field(default_factory=_now)
224
+ delivery_context: Optional[str] = None
225
+ acknowledged: bool = False
226
+ applied: bool = False
227
+ application_evidence_refs: list[str] = Field(default_factory=list)
228
+
229
+
230
+ class LessonEvaluation(BaseModel):
231
+ """Lesson 效果评估 — 记录效果、实验设计、负面证据与处置"""
232
+ evaluation_id: str = Field(default_factory=lambda: f"leval_{uuid4().hex[:12]}")
233
+ lesson_id: str
234
+ method: EvaluationMethod
235
+ population: dict[str, Any] = Field(default_factory=dict)
236
+ results: dict[str, Any] = Field(default_factory=lambda: {
237
+ "with_lesson": {"tasks": 0, "successes": 0},
238
+ "baseline": {"tasks": 0, "successes": 0},
239
+ "estimated_effect": None,
240
+ "uncertainty_interval": None,
241
+ })
242
+ decision: str = Field(default="keep_active") # keep_active | restrict_scope | review | deprecate | reject
243
+ evidence_refs: list[str] = Field(default_factory=list)
244
+ created_at: datetime = Field(default_factory=_now)
245
+
246
+
247
+ class LessonRollback(BaseModel):
248
+ """Lesson 回滚 — 坏 Lesson 撤回的审计记录"""
249
+ rollback_id: str = Field(default_factory=lambda: f"rollback_{uuid4().hex[:12]}")
250
+ lesson_id: str
251
+ reason: str
252
+ previous_status: LessonStatus
253
+ new_status: LessonStatus
254
+ affected_deliveries: list[str] = Field(default_factory=list)
255
+ replacement_lesson_id: Optional[str] = None
256
+ evidence_refs: list[str] = Field(default_factory=list)
257
+ created_at: datetime = Field(default_factory=_now)
258
+
259
+
260
+ class TrainingCandidate(BaseModel):
261
+ """训练候选 — 将经验转化为训练资产的审批通道"""
262
+ candidate_id: str = Field(default_factory=lambda: f"train_{uuid4().hex[:12]}")
263
+ source_experience_ids: list[str] = Field(default_factory=list)
264
+ format: TrainingFormat
265
+ validation: dict[str, bool] = Field(default_factory=lambda: {
266
+ "outcome_verified": False,
267
+ "human_reviewed": False,
268
+ "privacy_reviewed": False,
269
+ "license_reviewed": False,
270
+ })
271
+ status: TrainingStatus = TrainingStatus.CANDIDATE
272
+ artifact_ref: Optional[str] = None
273
+ created_at: datetime = Field(default_factory=_now)
274
+
275
+
276
+ # ─── High-level Schema Validation ────────────────────────────
277
+
278
+ def validate_lesson_scope(lesson: Lesson) -> list[str]:
279
+ """检查 Lesson 的作用域定义是否完整"""
280
+ issues = []
281
+ if not lesson.scope.get("task_domains"):
282
+ issues.append("Lesson missing task_domains in scope")
283
+ if not lesson.trigger.get("conditions"):
284
+ issues.append("Lesson missing trigger conditions")
285
+ return issues
286
+
287
+
288
+ def check_lesson_conflict(a: Lesson, b: Lesson) -> bool:
289
+ """检查两条 Lesson 是否有冲突
290
+
291
+ 先比较 scope,再比较建议内容。
292
+ 不同 scope 的两条建议即使语义相反也不应判定为冲突。
293
+ """
294
+ # 如果 scope 完全不重叠,不算冲突
295
+ a_domains = set(a.scope.get("task_domains", []))
296
+ b_domains = set(b.scope.get("task_domains", []))
297
+ if a_domains and b_domains and not a_domains & b_domains:
298
+ return False
299
+ a_versions = set(a.scope.get("agent_versions", []))
300
+ b_versions = set(b.scope.get("agent_versions", []))
301
+ if a_versions and b_versions and not a_versions & b_versions:
302
+ return False
303
+ return True