ducklm/duck_core/experience/recorder.py

173 lines
5.8 KiB
Python

import json
from pathlib import Path
import aiosqlite
from pydantic import BaseModel
from duck_core.tasks.store import utc_now
class ExperienceRecord(BaseModel):
id: int | None = None
task_id: str
skill_id: str | None = None
summary: str
result: str
what_worked: list[str] = []
what_failed: list[str] = []
reusable_lesson: str | None = None
suggested_skill_patch: str | None = None
confidence: float | None = None
created_at: str
class ExperienceRecorder:
def __init__(self, db_path: str):
self.db_path = Path(db_path)
async def init(self) -> None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
create table if not exists experience_records (
id integer primary key autoincrement,
task_id text not null,
skill_id text,
summary text not null,
result text not null,
what_worked_json text,
what_failed_json text,
reusable_lesson text,
suggested_skill_patch text,
confidence real,
created_at text not null
)
"""
)
await db.commit()
async def record(
self,
task_id: str,
summary: str,
result: str,
skill_id: str | None = None,
what_worked: list[str] | None = None,
what_failed: list[str] | None = None,
reusable_lesson: str | None = None,
suggested_skill_patch: str | None = None,
confidence: float | None = None,
) -> ExperienceRecord:
await self.init()
now = utc_now()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
insert into experience_records(
task_id, skill_id, summary, result, what_worked_json,
what_failed_json, reusable_lesson, suggested_skill_patch,
confidence, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
skill_id,
summary,
result,
json.dumps(what_worked or []),
json.dumps(what_failed or []),
reusable_lesson,
suggested_skill_patch,
confidence,
now,
),
)
await db.commit()
row_id = cursor.lastrowid
if suggested_skill_patch and skill_id:
self.write_skill_update_proposal(task_id, skill_id, suggested_skill_patch)
return ExperienceRecord(
id=row_id,
task_id=task_id,
skill_id=skill_id,
summary=summary,
result=result,
what_worked=what_worked or [],
what_failed=what_failed or [],
reusable_lesson=reusable_lesson,
suggested_skill_patch=suggested_skill_patch,
confidence=confidence,
created_at=now,
)
async def list_records(self) -> list[ExperienceRecord]:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from experience_records order by created_at desc"
)
rows = await cursor.fetchall()
return [self._row_to_record(row) for row in rows]
async def get_record(self, record_id: int) -> ExperienceRecord | None:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from experience_records where id = ?", (record_id,)
)
row = await cursor.fetchone()
return self._row_to_record(row) if row else None
def write_skill_update_proposal(self, task_id: str, skill_id: str, patch: str) -> Path:
directory = Path("skills/_proposals")
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{utc_now().replace(':', '').replace('+', '_')}_{skill_id}.patch.md"
path.write_text(
"\n".join(
[
"# Skill update proposal",
"",
f"Skill: {skill_id}",
"",
"## Reason",
"",
"Reflection suggested a reusable skill improvement.",
"",
"## Proposed changes",
"",
patch,
"",
"## Evidence",
"",
f"Task id: {task_id}",
"",
"## Risk",
"",
"Low.",
"",
"## Requires human approval",
"",
"Yes.",
]
)
)
return path
def _row_to_record(self, row: aiosqlite.Row) -> ExperienceRecord:
return ExperienceRecord(
id=row["id"],
task_id=row["task_id"],
skill_id=row["skill_id"],
summary=row["summary"],
result=row["result"],
what_worked=json.loads(row["what_worked_json"] or "[]"),
what_failed=json.loads(row["what_failed_json"] or "[]"),
reusable_lesson=row["reusable_lesson"],
suggested_skill_patch=row["suggested_skill_patch"],
confidence=row["confidence"],
created_at=row["created_at"],
)