-
Notifications
You must be signed in to change notification settings - Fork 12
feat(p2c): persist CodeMemory experiences to SQLite. Related to #162 #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """repro_code_experience table for CodeMemory persistence | ||
|
|
||
| Revision ID: 0021_repro_code_experience | ||
| Revises: 0020_memory_embedding | ||
| Create Date: 2026-03-03 | ||
|
|
||
| Issue #162: Persist CodeMemory experience data so it survives process restarts. | ||
| Creates the repro_code_experience table with indexes on paper_id and pack_id. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import sqlalchemy as sa | ||
| from alembic import op | ||
|
|
||
| revision = "0021_repro_code_experience" | ||
| down_revision = "0020_memory_embedding" | ||
| branch_labels = None | ||
| depends_on = None | ||
|
|
||
|
|
||
| def upgrade() -> None: | ||
| op.create_table( | ||
| "repro_code_experience", | ||
| sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), | ||
| sa.Column("pack_id", sa.String(64), nullable=True), | ||
| sa.Column("paper_id", sa.String(256), nullable=True), | ||
| sa.Column("pattern_type", sa.String(32), nullable=False), | ||
| sa.Column("content", sa.Text(), nullable=False, server_default=""), | ||
| sa.Column("code_snippet", sa.Text(), nullable=True), | ||
| sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), | ||
| sa.PrimaryKeyConstraint("id"), | ||
| ) | ||
| op.create_index("ix_repro_code_experience_paper_id", "repro_code_experience", ["paper_id"]) | ||
| op.create_index("ix_repro_code_experience_pack_id", "repro_code_experience", ["pack_id"]) | ||
| op.create_index("ix_repro_code_experience_pattern_type", "repro_code_experience", ["pattern_type"]) | ||
|
|
||
|
|
||
| def downgrade() -> None: | ||
| op.drop_index("ix_repro_code_experience_pattern_type", table_name="repro_code_experience") | ||
| op.drop_index("ix_repro_code_experience_pack_id", table_name="repro_code_experience") | ||
| op.drop_index("ix_repro_code_experience_paper_id", table_name="repro_code_experience") | ||
| op.drop_table("repro_code_experience") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| """SQLAlchemy store for ReproCodeExperienceModel (issue #162).""" | ||
| from __future__ import annotations | ||
|
|
||
| from datetime import datetime, timezone | ||
| from typing import Any, Dict, List, Optional | ||
|
|
||
| from sqlalchemy import select | ||
|
|
||
| from paperbot.infrastructure.stores.models import Base, ReproCodeExperienceModel | ||
| from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url | ||
|
|
||
| _VALID_TYPES = {"success_pattern", "failure_reason", "verified_structure"} | ||
|
|
||
|
|
||
| class ReproExperienceStore: | ||
| """CRUD store for persisted code generation experiences.""" | ||
|
|
||
| def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = True): | ||
| self.db_url = db_url or get_db_url() | ||
| self._provider = SessionProvider(self.db_url) | ||
| if auto_create_schema: | ||
| Base.metadata.create_all(self._provider.engine) | ||
|
|
||
| def add( | ||
| self, | ||
| *, | ||
| pattern_type: str, | ||
| content: str, | ||
| paper_id: Optional[str] = None, | ||
| pack_id: Optional[str] = None, | ||
| code_snippet: Optional[str] = None, | ||
| ) -> ReproCodeExperienceModel: | ||
| """Persist one experience record. Returns the saved row.""" | ||
| if pattern_type not in _VALID_TYPES: | ||
| raise ValueError(f"pattern_type must be one of {_VALID_TYPES}") | ||
| now = datetime.now(timezone.utc) | ||
| row = ReproCodeExperienceModel( | ||
| pack_id=pack_id, | ||
| paper_id=paper_id, | ||
| pattern_type=pattern_type, | ||
| content=(content or "").strip(), | ||
| code_snippet=code_snippet, | ||
| created_at=now, | ||
| ) | ||
| with self._provider.session() as session: | ||
| session.add(row) | ||
| session.commit() | ||
| session.refresh(row) | ||
| return row | ||
|
|
||
| def get_by_paper_id( | ||
| self, | ||
| paper_id: str, | ||
| *, | ||
| pattern_type: Optional[str] = None, | ||
| limit: int = 50, | ||
| ) -> List[Dict[str, Any]]: | ||
| """Retrieve experiences for a specific paper, newest first.""" | ||
| with self._provider.session() as session: | ||
| stmt = ( | ||
| select(ReproCodeExperienceModel) | ||
| .where(ReproCodeExperienceModel.paper_id == paper_id) | ||
| ) | ||
| if pattern_type: | ||
| stmt = stmt.where(ReproCodeExperienceModel.pattern_type == pattern_type) | ||
| stmt = stmt.order_by(ReproCodeExperienceModel.created_at.desc()).limit(limit) | ||
| rows = session.execute(stmt).scalars().all() | ||
| return [self._to_dict(r) for r in rows] | ||
|
|
||
| def get_by_pack_id( | ||
| self, | ||
| pack_id: str, | ||
| *, | ||
| pattern_type: Optional[str] = None, | ||
| limit: int = 50, | ||
| ) -> List[Dict[str, Any]]: | ||
| """Retrieve experiences for a specific P2C pack, newest first.""" | ||
| with self._provider.session() as session: | ||
| stmt = ( | ||
| select(ReproCodeExperienceModel) | ||
| .where(ReproCodeExperienceModel.pack_id == pack_id) | ||
| ) | ||
| if pattern_type: | ||
| stmt = stmt.where(ReproCodeExperienceModel.pattern_type == pattern_type) | ||
| stmt = stmt.order_by(ReproCodeExperienceModel.created_at.desc()).limit(limit) | ||
| rows = session.execute(stmt).scalars().all() | ||
| return [self._to_dict(r) for r in rows] | ||
|
|
||
| @staticmethod | ||
| def _to_dict(r: ReproCodeExperienceModel) -> Dict[str, Any]: | ||
| return { | ||
| "id": r.id, | ||
| "pack_id": r.pack_id, | ||
| "paper_id": r.paper_id, | ||
| "pattern_type": r.pattern_type, | ||
| "content": r.content, | ||
| "code_snippet": r.code_snippet, | ||
| "created_at": r.created_at.isoformat() if r.created_at else None, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,10 +15,13 @@ | |
| import re | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path | ||
| from typing import Dict, List, Optional, Set, Tuple | ||
| from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple | ||
|
|
||
| from .symbol_index import SymbolIndex, SymbolInfo | ||
|
|
||
| if TYPE_CHECKING: | ||
| from paperbot.infrastructure.stores.repro_experience_store import ReproExperienceStore | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
|
|
@@ -58,17 +61,25 @@ class CodeMemory: | |
| # Approximate tokens per character (conservative estimate) | ||
| CHARS_PER_TOKEN = 4 | ||
|
|
||
| def __init__(self, max_context_tokens: int = 8000): | ||
| def __init__( | ||
| self, | ||
| max_context_tokens: int = 8000, | ||
| experience_store: "Optional[ReproExperienceStore]" = None, | ||
| ): | ||
| """ | ||
| Initialize CodeMemory. | ||
|
|
||
| Args: | ||
| max_context_tokens: Maximum tokens for context injection | ||
| experience_store: Optional store for persisting/loading experiences | ||
| """ | ||
| self.max_context_tokens = max_context_tokens | ||
| self._files: Dict[str, FileInfo] = {} | ||
| self._symbol_index = SymbolIndex() | ||
| self._generation_order: List[str] = [] | ||
| self._experience_store: "Optional[ReproExperienceStore]" = experience_store | ||
| # Prior experiences loaded from DB for context injection | ||
| self._prior_experiences: List[Dict] = [] | ||
|
|
||
| def add_file(self, path: str, content: str, purpose: str = "") -> None: | ||
| """ | ||
|
|
@@ -198,6 +209,19 @@ def get_relevant_context( | |
| if len(interfaces) < remaining_chars: | ||
| context_parts.append(f"\n# === Available Interfaces ===\n{interfaces}") | ||
|
|
||
| # 4. Prior experiences from DB (success patterns / verified structures) | ||
| if self._prior_experiences and remaining_chars > 200: | ||
| exp_lines = [] | ||
| for exp in self._prior_experiences[:5]: | ||
| ptype = exp.get("pattern_type", "") | ||
| content = exp.get("content", "") | ||
| if ptype in ("success_pattern", "verified_structure") and content: | ||
| exp_lines.append(f" [{ptype}] {content}") | ||
| if exp_lines: | ||
| prior_ctx = "# === Prior Experience (same paper) ===\n" + "\n".join(exp_lines) | ||
| if len(prior_ctx) < remaining_chars: | ||
| context_parts.append(prior_ctx) | ||
|
Comment on lines
+212
to
+223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| return "\n\n".join(context_parts) | ||
|
|
||
| def _predict_dependencies(self, current_file: str) -> List[str]: | ||
|
|
@@ -325,11 +349,108 @@ def get_dependency_graph(self) -> Dict[str, Set[str]]: | |
| """Get the file dependency graph.""" | ||
| return {path: info.dependencies for path, info in self._files.items()} | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Persistence helpers (issue #162) | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def load_experiences_from_db( | ||
| self, | ||
| paper_id: str, | ||
| *, | ||
| pack_id: Optional[str] = None, | ||
| limit: int = 20, | ||
| ) -> None: | ||
| """Pre-load prior experiences for *paper_id* from the DB into memory. | ||
|
|
||
| Loaded records are stored in ``_prior_experiences`` and injected into | ||
| ``get_relevant_context()`` so the LLM can see what worked before. | ||
| """ | ||
| if not self._experience_store or not paper_id: | ||
| return | ||
| try: | ||
| rows = self._experience_store.get_by_paper_id(paper_id, limit=limit) | ||
| if pack_id: | ||
| pack_rows = self._experience_store.get_by_pack_id(pack_id, limit=limit) | ||
| seen_ids = {r["id"] for r in rows} | ||
| rows += [r for r in pack_rows if r["id"] not in seen_ids] | ||
| self._prior_experiences = rows | ||
| logger.debug("Loaded %d prior experiences for paper_id=%s", len(rows), paper_id) | ||
| except Exception: # noqa: BLE001 | ||
| logger.debug("Failed to load experiences from DB", exc_info=True) | ||
|
|
||
| def record_success_pattern( | ||
| self, | ||
| *, | ||
| paper_id: Optional[str], | ||
| pack_id: Optional[str] = None, | ||
| filepath: str, | ||
| code_snippet: Optional[str] = None, | ||
| ) -> None: | ||
| """Record that *filepath* was successfully generated (best-effort).""" | ||
| if not self._experience_store: | ||
| return | ||
| try: | ||
| self._experience_store.add( | ||
| pattern_type="success_pattern", | ||
| content=f"Successfully generated {filepath}", | ||
| paper_id=paper_id, | ||
| pack_id=pack_id, | ||
| code_snippet=(code_snippet or "")[:2000] or None, | ||
| ) | ||
| except Exception: # noqa: BLE001 | ||
| logger.debug("Failed to record success_pattern", exc_info=True) | ||
|
|
||
| def record_verified_structure( | ||
| self, | ||
| *, | ||
| paper_id: Optional[str], | ||
| pack_id: Optional[str] = None, | ||
| description: str, | ||
| code_snippet: Optional[str] = None, | ||
| ) -> None: | ||
| """Record that a code structure passed verification (best-effort).""" | ||
| if not self._experience_store: | ||
| return | ||
| try: | ||
| self._experience_store.add( | ||
| pattern_type="verified_structure", | ||
| content=description, | ||
| paper_id=paper_id, | ||
| pack_id=pack_id, | ||
| code_snippet=(code_snippet or "")[:2000] or None, | ||
| ) | ||
| except Exception: # noqa: BLE001 | ||
| logger.debug("Failed to record verified_structure", exc_info=True) | ||
|
|
||
| def record_failure_reason( | ||
| self, | ||
| *, | ||
| paper_id: Optional[str], | ||
| pack_id: Optional[str] = None, | ||
| error_type: str, | ||
| fix_applied: str, | ||
| code_snippet: Optional[str] = None, | ||
| ) -> None: | ||
| """Record a debugging fix so future runs can avoid the same error (best-effort).""" | ||
| if not self._experience_store: | ||
| return | ||
| try: | ||
| self._experience_store.add( | ||
| pattern_type="failure_reason", | ||
| content=f"[{error_type}] fixed: {fix_applied}", | ||
| paper_id=paper_id, | ||
| pack_id=pack_id, | ||
| code_snippet=(code_snippet or "")[:2000] or None, | ||
| ) | ||
| except Exception: # noqa: BLE001 | ||
| logger.debug("Failed to record failure_reason", exc_info=True) | ||
|
|
||
| def clear(self) -> None: | ||
| """Clear all memory.""" | ||
| self._files.clear() | ||
| self._symbol_index = SymbolIndex() | ||
| self._generation_order.clear() | ||
| self._prior_experiences.clear() | ||
|
|
||
| @property | ||
| def files(self) -> Dict[str, str]: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silently swallowing exceptions with
except Exception: passcan hide important issues with the database or persistence logic. It's better to log the exception, even if you don't want it to interrupt the agent's execution. This will make debugging much easier if experiences are not being saved as expected. Consider usingself.logger.warningwithexc_info=True.