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/storage.py ADDED
@@ -0,0 +1,459 @@
1
+ """存储层 — SQLite + artifact directory"""
2
+
3
+ import json
4
+ import sqlite3
5
+ import shutil
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from orp.schema import (
11
+ ExperienceRecord, Lesson, EvalArtifact, CounterfactualReplay,
12
+ LessonDelivery, LessonEvaluation, LessonRollback, TrainingCandidate,
13
+ TimelineEvent, Outcome, ReflectionAnalysis, Feedback,
14
+ LessonStatus, TrustLevel, TrainingStatus, TrainingFormat, DeliveryStrategy,
15
+ )
16
+
17
+
18
+ DEFAULT_ORP_DIR = Path.home() / ".orp"
19
+
20
+
21
+ def _ensure_dir(path: Path) -> Path:
22
+ path.mkdir(parents=True, exist_ok=True)
23
+ return path
24
+
25
+
26
+ class ORPStorage:
27
+ """ORP 本地存储 — SQLite + artifact directory"""
28
+
29
+ def __init__(self, base_dir: Optional[Path] = None):
30
+ self.base = Path(base_dir) if base_dir else DEFAULT_ORP_DIR
31
+ self.db_path = self.base / "orp.db"
32
+ self.artifact_dir = _ensure_dir(self.base / "artifacts")
33
+ self.eval_dir = _ensure_dir(self.base / "evals")
34
+ self.report_dir = _ensure_dir(self.base / "reports")
35
+ self._conn: Optional[sqlite3.Connection] = None
36
+
37
+ @property
38
+ def conn(self) -> sqlite3.Connection:
39
+ if self._conn is None:
40
+ self._conn = sqlite3.connect(str(self.db_path), timeout=5)
41
+ self._conn.row_factory = sqlite3.Row
42
+ self._conn.execute("PRAGMA journal_mode=WAL")
43
+ self._conn.execute("PRAGMA busy_timeout=5000")
44
+ self._init_schema()
45
+ return self._conn
46
+
47
+ def _init_schema(self) -> None:
48
+ """初始化数据库 schema"""
49
+ self._conn.executescript("""
50
+ CREATE TABLE IF NOT EXISTS experiences (
51
+ experience_id TEXT PRIMARY KEY,
52
+ orp_version TEXT NOT NULL DEFAULT '0.3',
53
+ trace_ref TEXT,
54
+ agent_json TEXT NOT NULL,
55
+ task_json TEXT NOT NULL,
56
+ outcome_json TEXT NOT NULL,
57
+ reflection_json TEXT,
58
+ artifacts_json TEXT,
59
+ created_at TEXT NOT NULL
60
+ );
61
+ CREATE TABLE IF NOT EXISTS timeline_events (
62
+ event_id TEXT PRIMARY KEY,
63
+ experience_id TEXT NOT NULL REFERENCES experiences(experience_id),
64
+ kind TEXT NOT NULL,
65
+ source TEXT NOT NULL DEFAULT 'agent',
66
+ content TEXT NOT NULL,
67
+ evidence_refs_json TEXT,
68
+ parent_event TEXT,
69
+ timestamp TEXT NOT NULL
70
+ );
71
+ CREATE TABLE IF NOT EXISTS lessons (
72
+ lesson_id TEXT PRIMARY KEY,
73
+ trigger_json TEXT NOT NULL,
74
+ recommendation TEXT NOT NULL,
75
+ provenance_json TEXT,
76
+ scope_json TEXT,
77
+ relationships_json TEXT,
78
+ validation_json TEXT,
79
+ metrics_json TEXT,
80
+ status TEXT NOT NULL DEFAULT 'candidate',
81
+ expires_at TEXT,
82
+ created_at TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL
84
+ );
85
+ CREATE TABLE IF NOT EXISTS evals (
86
+ eval_id TEXT PRIMARY KEY,
87
+ origin_experience TEXT NOT NULL,
88
+ runner TEXT NOT NULL DEFAULT 'pytest',
89
+ command TEXT NOT NULL,
90
+ expected_json TEXT,
91
+ generated_by TEXT DEFAULT 'agent',
92
+ review_json TEXT,
93
+ last_result_json TEXT,
94
+ created_at TEXT NOT NULL
95
+ );
96
+ CREATE TABLE IF NOT EXISTS replays (
97
+ replay_id TEXT PRIMARY KEY,
98
+ experience_id TEXT NOT NULL,
99
+ original_strategy TEXT NOT NULL,
100
+ alternative_strategy TEXT NOT NULL,
101
+ verification_mode TEXT DEFAULT 'sandbox_replay',
102
+ result_json TEXT,
103
+ created_at TEXT NOT NULL
104
+ );
105
+ CREATE TABLE IF NOT EXISTS deliveries (
106
+ delivery_id TEXT PRIMARY KEY,
107
+ lesson_id TEXT NOT NULL,
108
+ experience_id TEXT NOT NULL,
109
+ strategy TEXT NOT NULL,
110
+ delivered_at TEXT NOT NULL,
111
+ delivery_context TEXT,
112
+ acknowledged INTEGER DEFAULT 0,
113
+ applied INTEGER DEFAULT 0,
114
+ application_evidence_json TEXT
115
+ );
116
+ CREATE TABLE IF NOT EXISTS lesson_evals (
117
+ evaluation_id TEXT PRIMARY KEY,
118
+ lesson_id TEXT NOT NULL,
119
+ method TEXT NOT NULL,
120
+ population_json TEXT,
121
+ results_json TEXT,
122
+ decision TEXT DEFAULT 'keep_active',
123
+ evidence_refs_json TEXT,
124
+ created_at TEXT NOT NULL
125
+ );
126
+ CREATE TABLE IF NOT EXISTS rollbacks (
127
+ rollback_id TEXT PRIMARY KEY,
128
+ lesson_id TEXT NOT NULL,
129
+ reason TEXT NOT NULL,
130
+ previous_status TEXT NOT NULL,
131
+ new_status TEXT NOT NULL,
132
+ affected_deliveries_json TEXT,
133
+ replacement_lesson_id TEXT,
134
+ evidence_refs_json TEXT,
135
+ created_at TEXT NOT NULL
136
+ );
137
+ CREATE TABLE IF NOT EXISTS training_candidates (
138
+ candidate_id TEXT PRIMARY KEY,
139
+ source_experience_ids_json TEXT,
140
+ format TEXT NOT NULL,
141
+ validation_json TEXT,
142
+ status TEXT NOT NULL DEFAULT 'candidate',
143
+ artifact_ref TEXT,
144
+ created_at TEXT NOT NULL
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_timeline_exp ON timeline_events(experience_id);
147
+ CREATE INDEX IF NOT EXISTS idx_lessons_status ON lessons(status);
148
+ CREATE INDEX IF NOT EXISTS idx_deliveries_lesson ON deliveries(lesson_id);
149
+ CREATE INDEX IF NOT EXISTS idx_lesson_evals_lesson ON lesson_evals(lesson_id);
150
+ """)
151
+
152
+ # ─── Experience ───
153
+
154
+ def save_experience(self, exp: ExperienceRecord) -> None:
155
+ self.conn.execute("""
156
+ INSERT OR REPLACE INTO experiences
157
+ (experience_id, orp_version, trace_ref, agent_json, task_json,
158
+ outcome_json, reflection_json, artifacts_json, created_at)
159
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
160
+ """, (
161
+ exp.experience_id, exp.orp_version, exp.trace_ref,
162
+ json.dumps(exp.agent), json.dumps(exp.task),
163
+ exp.outcome.model_dump_json(),
164
+ json.dumps(exp.reflection.model_dump()) if exp.reflection else None,
165
+ json.dumps(exp.artifacts), exp.created_at.isoformat(),
166
+ ))
167
+ self.conn.commit()
168
+ for evt in exp.timeline:
169
+ self.conn.execute("""
170
+ INSERT OR REPLACE INTO timeline_events
171
+ (event_id, experience_id, kind, source, content,
172
+ evidence_refs_json, parent_event, timestamp)
173
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
174
+ """, (
175
+ evt.id, exp.experience_id, evt.kind.value, evt.source,
176
+ evt.content, json.dumps(evt.evidence_refs),
177
+ evt.parent_event, evt.timestamp.isoformat(),
178
+ ))
179
+ # Save feedback
180
+ for fb in exp.feedback:
181
+ self._save_feedback(exp.experience_id, fb)
182
+
183
+ def get_experience(self, experience_id: str) -> Optional[ExperienceRecord]:
184
+ row = self.conn.execute(
185
+ "SELECT * FROM experiences WHERE experience_id = ?",
186
+ (experience_id,)
187
+ ).fetchone()
188
+ if not row:
189
+ return None
190
+ return self._row_to_experience(row)
191
+
192
+ def list_experiences(self, limit: int = 20, offset: int = 0) -> list[ExperienceRecord]:
193
+ rows = self.conn.execute(
194
+ "SELECT * FROM experiences ORDER BY created_at DESC LIMIT ? OFFSET ?",
195
+ (limit, offset)
196
+ ).fetchall()
197
+ return [self._row_to_experience(r) for r in rows]
198
+
199
+ def _row_to_experience(self, row: sqlite3.Row) -> ExperienceRecord:
200
+ events = self.conn.execute(
201
+ "SELECT * FROM timeline_events WHERE experience_id = ? ORDER BY timestamp",
202
+ (row["experience_id"],)
203
+ ).fetchall()
204
+ return ExperienceRecord(
205
+ orp_version=row["orp_version"],
206
+ experience_id=row["experience_id"],
207
+ trace_ref=row["trace_ref"],
208
+ agent=json.loads(row["agent_json"]),
209
+ task=json.loads(row["task_json"]),
210
+ timeline=[TimelineEvent(**{
211
+ "id": e["event_id"],
212
+ "kind": e["kind"],
213
+ "source": e["source"],
214
+ "content": e["content"],
215
+ "evidence_refs": json.loads(e["evidence_refs_json"]) if e["evidence_refs_json"] else [],
216
+ "parent_event": e["parent_event"],
217
+ "timestamp": e["timestamp"],
218
+ }) for e in events],
219
+ outcome=Outcome(**json.loads(row["outcome_json"])),
220
+ reflection=ReflectionAnalysis(**json.loads(row["reflection_json"])) if row["reflection_json"] else None,
221
+ artifacts=json.loads(row["artifacts_json"]) if row["artifacts_json"] else {},
222
+ created_at=datetime.fromisoformat(row["created_at"]),
223
+ )
224
+
225
+ # ─── Lesson ───
226
+
227
+ def save_lesson(self, lesson: Lesson) -> None:
228
+ self.conn.execute("""
229
+ INSERT OR REPLACE INTO lessons
230
+ (lesson_id, trigger_json, recommendation, provenance_json,
231
+ scope_json, relationships_json, validation_json, metrics_json,
232
+ status, expires_at, created_at, updated_at)
233
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
234
+ """, (
235
+ lesson.lesson_id, json.dumps(lesson.trigger), lesson.recommendation,
236
+ json.dumps(lesson.provenance), json.dumps(lesson.scope),
237
+ json.dumps(lesson.relationships), json.dumps(lesson.validation),
238
+ json.dumps(lesson.metrics), lesson.status.value,
239
+ lesson.expires_at.isoformat() if lesson.expires_at else None,
240
+ lesson.created_at.isoformat(), lesson.updated_at.isoformat(),
241
+ ))
242
+ self.conn.commit()
243
+
244
+ def get_lesson(self, lesson_id: str) -> Optional[Lesson]:
245
+ row = self.conn.execute(
246
+ "SELECT * FROM lessons WHERE lesson_id = ?", (lesson_id,)
247
+ ).fetchone()
248
+ if not row:
249
+ return None
250
+ return self._row_to_lesson(row)
251
+
252
+ def list_lessons(self, status: Optional[LessonStatus] = None, limit: int = 50) -> list[Lesson]:
253
+ if status:
254
+ rows = self.conn.execute(
255
+ "SELECT * FROM lessons WHERE status = ? ORDER BY updated_at DESC LIMIT ?",
256
+ (status.value, limit)
257
+ ).fetchall()
258
+ else:
259
+ rows = self.conn.execute(
260
+ "SELECT * FROM lessons ORDER BY updated_at DESC LIMIT ?", (limit,)
261
+ ).fetchall()
262
+ return [self._row_to_lesson(r) for r in rows]
263
+
264
+ def _row_to_lesson(self, row: sqlite3.Row) -> Lesson:
265
+ return Lesson(
266
+ lesson_id=row["lesson_id"],
267
+ trigger=json.loads(row["trigger_json"]),
268
+ recommendation=row["recommendation"],
269
+ provenance=json.loads(row["provenance_json"]) if row["provenance_json"] else {},
270
+ scope=json.loads(row["scope_json"]) if row["scope_json"] else {},
271
+ relationships=json.loads(row["relationships_json"]) if row["relationships_json"] else {},
272
+ validation=json.loads(row["validation_json"]) if row["validation_json"] else {},
273
+ metrics=json.loads(row["metrics_json"]) if row["metrics_json"] else {},
274
+ status=LessonStatus(row["status"]),
275
+ expires_at=datetime.fromisoformat(row["expires_at"]) if row["expires_at"] else None,
276
+ created_at=datetime.fromisoformat(row["created_at"]),
277
+ updated_at=datetime.fromisoformat(row["updated_at"]),
278
+ )
279
+
280
+ def update_lesson_status(self, lesson_id: str, status: LessonStatus) -> None:
281
+ self.conn.execute(
282
+ "UPDATE lessons SET status = ?, updated_at = ? WHERE lesson_id = ?",
283
+ (status.value, datetime.utcnow().isoformat(), lesson_id)
284
+ )
285
+
286
+ # ─── Eval ───
287
+
288
+ def save_eval(self, eval_: EvalArtifact) -> None:
289
+ self.conn.execute("""
290
+ INSERT OR REPLACE INTO evals
291
+ (eval_id, origin_experience, runner, command, expected_json,
292
+ generated_by, review_json, last_result_json, created_at)
293
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
294
+ """, (
295
+ eval_.eval_id, eval_.origin_experience, eval_.runner, eval_.command,
296
+ json.dumps(eval_.expected), eval_.generated_by,
297
+ json.dumps(eval_.review) if eval_.review else None,
298
+ json.dumps(eval_.last_result) if eval_.last_result else None,
299
+ eval_.created_at.isoformat(),
300
+ ))
301
+
302
+ # ─── Replay ───
303
+
304
+ def save_replay(self, replay: CounterfactualReplay) -> None:
305
+ self.conn.execute("""
306
+ INSERT OR REPLACE INTO replays
307
+ (replay_id, experience_id, original_strategy, alternative_strategy,
308
+ verification_mode, result_json, created_at)
309
+ VALUES (?, ?, ?, ?, ?, ?, ?)
310
+ """, (
311
+ replay.replay_id, replay.experience_id,
312
+ replay.original_strategy, replay.alternative_strategy,
313
+ replay.verification_mode, json.dumps(replay.result),
314
+ replay.created_at.isoformat(),
315
+ ))
316
+
317
+ # ─── Delivery ───
318
+
319
+ def save_delivery(self, delivery: LessonDelivery) -> None:
320
+ self.conn.execute("""
321
+ INSERT OR REPLACE INTO deliveries
322
+ (delivery_id, lesson_id, experience_id, strategy, delivered_at,
323
+ delivery_context, acknowledged, applied, application_evidence_json)
324
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
325
+ """, (
326
+ delivery.delivery_id, delivery.lesson_id, delivery.experience_id,
327
+ delivery.strategy.value, delivery.delivered_at.isoformat(),
328
+ delivery.delivery_context,
329
+ 1 if delivery.acknowledged else 0,
330
+ 1 if delivery.applied else 0,
331
+ json.dumps(delivery.application_evidence_refs),
332
+ ))
333
+
334
+ def get_deliveries_for_lesson(self, lesson_id: str) -> list[LessonDelivery]:
335
+ rows = self.conn.execute(
336
+ "SELECT * FROM deliveries WHERE lesson_id = ? ORDER BY delivered_at DESC",
337
+ (lesson_id,)
338
+ ).fetchall()
339
+ return [LessonDelivery(
340
+ delivery_id=r["delivery_id"], lesson_id=r["lesson_id"],
341
+ experience_id=r["experience_id"],
342
+ strategy=DeliveryStrategy(r["strategy"]),
343
+ delivered_at=datetime.fromisoformat(r["delivered_at"]),
344
+ delivery_context=r["delivery_context"],
345
+ acknowledged=bool(r["acknowledged"]),
346
+ applied=bool(r["applied"]),
347
+ application_evidence_refs=json.loads(r["application_evidence_json"]) if r["application_evidence_json"] else [],
348
+ ) for r in rows]
349
+
350
+ # ─── Lesson Evaluation ───
351
+
352
+ def save_lesson_evaluation(self, le: LessonEvaluation) -> None:
353
+ self.conn.execute("""
354
+ INSERT OR REPLACE INTO lesson_evals
355
+ (evaluation_id, lesson_id, method, population_json, results_json,
356
+ decision, evidence_refs_json, created_at)
357
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
358
+ """, (
359
+ le.evaluation_id, le.lesson_id, le.method.value,
360
+ json.dumps(le.population), json.dumps(le.results),
361
+ le.decision, json.dumps(le.evidence_refs),
362
+ le.created_at.isoformat(),
363
+ ))
364
+ self.conn.commit()
365
+
366
+ # ─── Rollback ───
367
+
368
+ def save_rollback(self, rollback: LessonRollback) -> None:
369
+ self.conn.execute("""
370
+ INSERT OR REPLACE INTO rollbacks
371
+ (rollback_id, lesson_id, reason, previous_status, new_status,
372
+ affected_deliveries_json, replacement_lesson_id, evidence_refs_json, created_at)
373
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
374
+ """, (
375
+ rollback.rollback_id, rollback.lesson_id, rollback.reason,
376
+ rollback.previous_status.value, rollback.new_status.value,
377
+ json.dumps(rollback.affected_deliveries),
378
+ rollback.replacement_lesson_id,
379
+ json.dumps(rollback.evidence_refs),
380
+ rollback.created_at.isoformat(),
381
+ ))
382
+
383
+ # ─── Training Candidate ───
384
+
385
+ def save_training_candidate(self, tc: TrainingCandidate) -> None:
386
+ self.conn.execute("""
387
+ INSERT OR REPLACE INTO training_candidates
388
+ (candidate_id, source_experience_ids_json, format, validation_json,
389
+ status, artifact_ref, created_at)
390
+ VALUES (?, ?, ?, ?, ?, ?, ?)
391
+ """, (
392
+ tc.candidate_id, json.dumps(tc.source_experience_ids),
393
+ tc.format.value, json.dumps(tc.validation),
394
+ tc.status.value, tc.artifact_ref, tc.created_at.isoformat(),
395
+ ))
396
+
397
+ def list_training_candidates(self, status: Optional[TrainingStatus] = None) -> list[TrainingCandidate]:
398
+ if status:
399
+ rows = self.conn.execute(
400
+ "SELECT * FROM training_candidates WHERE status = ? ORDER BY created_at DESC",
401
+ (status.value,)
402
+ ).fetchall()
403
+ else:
404
+ rows = self.conn.execute(
405
+ "SELECT * FROM training_candidates ORDER BY created_at DESC"
406
+ ).fetchall()
407
+ result = []
408
+ for r in rows:
409
+ tc = TrainingCandidate(
410
+ candidate_id=r["candidate_id"],
411
+ source_experience_ids=json.loads(r["source_experience_ids_json"]),
412
+ format=TrainingFormat(r["format"]),
413
+ validation=json.loads(r["validation_json"]) if r["validation_json"] else {},
414
+ status=TrainingStatus(r["status"]),
415
+ artifact_ref=r["artifact_ref"],
416
+ created_at=datetime.fromisoformat(r["created_at"]),
417
+ )
418
+ result.append(tc)
419
+ return result
420
+
421
+ # ─── Feedback (internal) ───
422
+
423
+ def _save_feedback(self, experience_id: str, fb: Feedback) -> None:
424
+ self.conn.execute("""
425
+ INSERT INTO feedback (experience_id, target_ref, source_type, source_id,
426
+ verdict, explanation, evidence_refs_json)
427
+ VALUES (?, ?, ?, ?, ?, ?, ?)
428
+ """, (
429
+ experience_id, fb.target_ref, fb.source_type.value, fb.source_id,
430
+ fb.verdict, fb.explanation, json.dumps(fb.evidence_refs),
431
+ ))
432
+
433
+ # ─── Utility ───
434
+
435
+ def save_artifact(self, name: str, content: str) -> str:
436
+ path = self.artifact_dir / name
437
+ path.write_text(content)
438
+ return str(path)
439
+
440
+ def close(self) -> None:
441
+ if self._conn:
442
+ try:
443
+ self._conn.commit()
444
+ except Exception:
445
+ pass
446
+ try:
447
+ self._conn.close()
448
+ except Exception:
449
+ pass
450
+ self._conn = None
451
+
452
+ def __enter__(self):
453
+ return self
454
+
455
+ def __exit__(self, *args):
456
+ self.close()
457
+
458
+ def __del__(self):
459
+ self.close()
orp/training.py ADDED
@@ -0,0 +1,94 @@
1
+ """Training Candidate Pipeline — 经验转训练数据"""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from orp.schema import (
6
+ ExperienceRecord, TrainingCandidate, TrainingFormat, TrainingStatus,
7
+ )
8
+ from orp.storage import ORPStorage
9
+
10
+
11
+ class TrainingPipeline:
12
+ """训练候选管道 — 生成待审批的训练数据"""
13
+
14
+ def __init__(self, storage: Optional[ORPStorage] = None):
15
+ self._storage = storage or ORPStorage()
16
+
17
+ def create_candidate(self, source_exp: ExperienceRecord,
18
+ format: TrainingFormat) -> Optional[TrainingCandidate]:
19
+ """从已验证的 Experience 创建训练候选"""
20
+ if source_exp.outcome.status != "success":
21
+ return None # 只从成功 trace 创建 SFT 候选
22
+
23
+ tc = TrainingCandidate(
24
+ source_experience_ids=[source_exp.experience_id],
25
+ format=format,
26
+ validation={
27
+ "outcome_verified": source_exp.outcome.status == "success",
28
+ "human_reviewed": False,
29
+ "privacy_reviewed": False,
30
+ "license_reviewed": False,
31
+ },
32
+ status=TrainingStatus.CANDIDATE,
33
+ )
34
+
35
+ # 生成 artifact
36
+ artifact = self._render_artifact(source_exp, format)
37
+ if artifact:
38
+ artifact_path = f"training_{tc.candidate_id}.jsonl"
39
+ self._storage.save_artifact(artifact_path, artifact)
40
+ tc.artifact_ref = f"artifact:{artifact_path}"
41
+
42
+ self._storage.save_training_candidate(tc)
43
+ return tc
44
+
45
+ def approve(self, candidate_id: str,
46
+ human_reviewed: bool = True,
47
+ privacy_reviewed: bool = True,
48
+ license_reviewed: bool = True) -> bool:
49
+ """审批训练候选(四重审批)"""
50
+ for tc in self._storage.list_training_candidates():
51
+ if tc.candidate_id == candidate_id:
52
+ tc.validation["human_reviewed"] = human_reviewed
53
+ tc.validation["privacy_reviewed"] = privacy_reviewed
54
+ tc.validation["license_reviewed"] = license_reviewed
55
+ all_ok = all(tc.validation.values())
56
+ tc.status = TrainingStatus.APPROVED if all_ok else TrainingStatus.CANDIDATE
57
+ self._storage.save_training_candidate(tc)
58
+ return all_ok
59
+ return False
60
+
61
+ def export_approved(self) -> list[dict[str, Any]]:
62
+ """导出所有已审批的训练候选"""
63
+ approved = [
64
+ tc for tc in self._storage.list_training_candidates()
65
+ if tc.status == TrainingStatus.APPROVED
66
+ ]
67
+ results = []
68
+ for tc in approved:
69
+ results.append({
70
+ "candidate_id": tc.candidate_id,
71
+ "format": tc.format.value,
72
+ "artifact_ref": tc.artifact_ref,
73
+ })
74
+ return results
75
+
76
+ def _render_artifact(self, exp: ExperienceRecord, fmt: TrainingFormat) -> Optional[str]:
77
+ """渲染训练数据 artifact"""
78
+ if fmt == TrainingFormat.SFT_EXAMPLE:
79
+ return self._render_sft(exp)
80
+ elif fmt == TrainingFormat.PREFERENCE_PAIR:
81
+ return self._render_preference(exp)
82
+ return None
83
+
84
+ def _render_sft(self, exp: ExperienceRecord) -> str:
85
+ """渲染为 SFT 示例"""
86
+ timeline_text = "\n".join(
87
+ f"[{e.kind.value}] {e.content[:200]}"
88
+ for e in exp.timeline
89
+ )
90
+ return f'{{"messages": [{{"role": "user", "content": "{exp.task.get("goal", "")}"}}, {{"role": "assistant", "content": "{timeline_text}"}}]}}\n'
91
+
92
+ def _render_preference(self, exp: ExperienceRecord) -> str:
93
+ """渲染为偏好对(DPO 格式)"""
94
+ return f'{{"prompt": "{exp.task.get("goal", "")}", "chosen": "success", "rejected": "failed"}}\n'
orp/viewer.py ADDED
@@ -0,0 +1,104 @@
1
+ """HTML Report Viewer"""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from orp.storage import ORPStorage
7
+ from orp.schema import LessonStatus
8
+
9
+
10
+ def _esc(s):
11
+ return (s.replace("&", "&amp;").replace("<", "&lt;")
12
+ .replace(">", "&gt;").replace(chr(34), "&quot;"))
13
+
14
+
15
+ class HTMLReporter:
16
+ def __init__(self, storage=None):
17
+ self._storage = storage or ORPStorage()
18
+
19
+ def _row(self, e):
20
+ k = e.kind.value
21
+ c = _esc(e.content[:200])
22
+ s = _esc(e.source)
23
+ r = ', '.join(_esc(x) for x in e.evidence_refs[:3])
24
+ out = '<tr>'
25
+ out += '<td class="kind-' + k + '">' + k + '</td>'
26
+ out += '<td>' + s + '</td>'
27
+ out += '<td>' + c + '</td>'
28
+ out += '<td>' + r + '</td>'
29
+ out += '</tr>'
30
+ return out
31
+
32
+ def _exp_html(self, exp):
33
+ rows = ''.join(self._row(e) for e in exp.timeline)
34
+ goal = _esc(exp.task.get('goal', '?'))
35
+ eid = _esc(exp.experience_id[:16]) + '...'
36
+ st = _esc(exp.outcome.status)
37
+ ag = _esc(exp.agent.get('id', '?'))
38
+ out = '<div class="card">'
39
+ out += '<h2>' + goal + '</h2>'
40
+ out += '<div class="meta">'
41
+ out += '<span class="tag">ID: ' + eid + '</span>'
42
+ out += '<span class="tag">Status: ' + st + '</span>'
43
+ out += '<span class="tag">Agent: ' + ag + '</span>'
44
+ out += '</div><h3>Timeline</h3><table>'
45
+ out += '<tr><th>Kind</th><th>Source</th><th>Content</th><th>Evidence</th></tr>'
46
+ out += rows + '</table></div>'
47
+ return out
48
+
49
+ def _lesson_row(self, l):
50
+ lid = _esc(l.lesson_id[:12]) + '...'
51
+ rec = _esc(l.recommendation[:100])
52
+ sc = 'status-' + l.status.value
53
+ sv = l.status.value
54
+ vl = l.validation.get('level', 'asserted')
55
+ out = '<tr>'
56
+ out += '<td>' + lid + '</td>'
57
+ out += '<td>' + rec + '</td>'
58
+ out += '<td><span class="' + sc + '">' + sv + '</span></td>'
59
+ out += '<td>' + vl + '</td></tr>'
60
+ return out
61
+
62
+ def render_report(self, title="ORP Report"):
63
+ exps = self._storage.list_experiences(limit=20)
64
+ lessons = self._storage.list_lessons(limit=20)
65
+ exp_html = ''.join(self._exp_html(e) for e in exps)
66
+ lesson_html = ''.join(self._lesson_row(l) for l in lessons)
67
+ st = _esc(title)
68
+ ec = str(len(exps))
69
+ lc = str(len(lessons))
70
+ ac = str(sum(1 for l in lessons if l.status == LessonStatus.ACTIVE))
71
+ html = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
72
+ html += '<title>' + st + '</title><style>'
73
+ html += 'body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:960px;margin:0 auto;padding:20px;background:#f5f5f5;}'
74
+ html += 'h1{color:#333;}.card{background:#fff;border-radius:8px;padding:20px;margin:16px 0;box-shadow:0 1px 3px rgba(0,0,0,0.1);}'
75
+ html += '.meta{margin:8px 0;}.tag{display:inline-block;background:#e0e7ff;padding:2px 8px;border-radius:4px;font-size:12px;margin:2px;}'
76
+ html += 'table{width:100%;border-collapse:collapse;margin:8px 0;}'
77
+ html += 'th,td{text-align:left;padding:6px 8px;border-bottom:1px solid #eee;font-size:13px;}'
78
+ html += 'th{background:#f9fafb;font-weight:600;}'
79
+ html += '.kind-observation{color:#2563eb;}.kind-claim{color:#d97706;}.kind-action{color:#059669;}'
80
+ html += '.kind-outcome{color:#7c3aed;}.kind-feedback{color:#0891b2;}'
81
+ html += '.status-active{color:#059669;font-weight:600;}.status-candidate{color:#d97706;}'
82
+ html += '.status-under_review{color:#dc2626;}.status-deprecated{color:#6b7280;}'
83
+ html += '.stats{display:flex;gap:16px;flex-wrap:wrap;}'
84
+ html += '.stat-box{background:#fff;border-radius:8px;padding:16px;flex:1;min-width:120px;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,0.1);}'
85
+ html += '.stat-value{font-size:28px;font-weight:700;color:#2563eb;}'
86
+ html += '.stat-label{font-size:12px;color:#6b7280;}'
87
+ html += '</style></head><body>'
88
+ html += '<h1>' + st + '</h1>'
89
+ html += '<div class="stats">'
90
+ html += '<div class="stat-box"><div class="stat-value">' + ec + '</div><div class="stat-label">Experiences</div></div>'
91
+ html += '<div class="stat-box"><div class="stat-value">' + lc + '</div><div class="stat-label">Lessons</div></div>'
92
+ html += '<div class="stat-box"><div class="stat-value">' + ac + '</div><div class="stat-label">Active</div></div>'
93
+ html += '</div><h2>Lessons</h2>'
94
+ html += '<div class="card"><table><tr><th>ID</th><th>Recommendation</th><th>Status</th><th>Validation</th></tr>'
95
+ html += lesson_html + '</table></div>'
96
+ html += '<h2>Recent Experiences</h2>'
97
+ html += exp_html
98
+ html += '<p style="color:#999;font-size:12px;text-align:center;margin-top:32px;">Generated by ORP v0.3</p>'
99
+ html += '</body></html>'
100
+ return html
101
+
102
+ def write_report(self, path="orp_report.html"):
103
+ Path(path).write_text(self.render_report(), encoding='utf-8')
104
+ return path