From 2ecdeb943272c54181e83ed7b88536dea171ff3d Mon Sep 17 00:00:00 2001 From: WenjingWang Date: Thu, 26 Feb 2026 22:31:33 +0100 Subject: [PATCH 1/2] feat(p2c): add ReproContextPack DB models and migration. Related to #139 --- ...be26e_add_p2c_repro_context_pack_tables.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 alembic/versions/b94c1a2be26e_add_p2c_repro_context_pack_tables.py diff --git a/alembic/versions/b94c1a2be26e_add_p2c_repro_context_pack_tables.py b/alembic/versions/b94c1a2be26e_add_p2c_repro_context_pack_tables.py new file mode 100644 index 00000000..d94f3a0b --- /dev/null +++ b/alembic/versions/b94c1a2be26e_add_p2c_repro_context_pack_tables.py @@ -0,0 +1,98 @@ +"""add_p2c_repro_context_pack_tables + +Revision ID: b94c1a2be26e +Revises: 4c71b28a2f67 +Create Date: 2026-02-26 16:34:35.636031 + +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = 'b94c1a2be26e' +down_revision = '4c71b28a2f67' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'repro_context_pack', + sa.Column('id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.String(length=64), nullable=False, server_default='default'), + sa.Column('project_id', sa.String(length=64), nullable=True), + sa.Column('paper_id', sa.String(length=256), nullable=False), + sa.Column('paper_title', sa.Text(), nullable=True), + sa.Column('version', sa.String(length=16), nullable=False, server_default='v1'), + sa.Column('depth', sa.String(length=16), nullable=False, server_default='standard'), + sa.Column('status', sa.String(length=32), nullable=False, server_default='pending'), + sa.Column('objective', sa.Text(), nullable=True), + sa.Column('pack_json', sa.Text(), nullable=False, server_default='{}'), + sa.Column('confidence_overall', sa.Float(), nullable=False, server_default='0.0'), + sa.Column('warning_count', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_repro_context_pack_user_id', 'repro_context_pack', ['user_id']) + op.create_index('ix_repro_context_pack_paper_id', 'repro_context_pack', ['paper_id']) + op.create_index('ix_repro_context_pack_project_id', 'repro_context_pack', ['project_id']) + op.create_index('ix_repro_context_pack_status', 'repro_context_pack', ['status']) + op.create_index('ix_repro_context_pack_confidence_overall', 'repro_context_pack', ['confidence_overall']) + op.create_index('ix_repro_context_pack_created_at', 'repro_context_pack', ['created_at']) + + op.create_table( + 'repro_context_stage_result', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('context_pack_id', sa.String(length=64), nullable=False), + sa.Column('stage_name', sa.String(length=64), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False, server_default='completed'), + sa.Column('result_json', sa.Text(), nullable=True), + sa.Column('confidence', sa.Float(), nullable=False, server_default='0.0'), + sa.Column('duration_ms', sa.Integer(), nullable=False, server_default='0'), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['context_pack_id'], ['repro_context_pack.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_repro_context_stage_result_context_pack_id', 'repro_context_stage_result', ['context_pack_id']) + op.create_index('ix_repro_context_stage_result_stage_name', 'repro_context_stage_result', ['stage_name']) + op.create_index('ix_repro_context_stage_result_status', 'repro_context_stage_result', ['status']) + + op.create_table( + 'repro_context_evidence', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('context_pack_id', sa.String(length=64), nullable=False), + sa.Column('evidence_type', sa.String(length=32), nullable=False), + sa.Column('ref', sa.Text(), nullable=False, server_default=''), + sa.Column('supports_json', sa.Text(), nullable=False, server_default='[]'), + sa.Column('confidence', sa.Float(), nullable=False, server_default='0.0'), + sa.ForeignKeyConstraint(['context_pack_id'], ['repro_context_pack.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_repro_context_evidence_context_pack_id', 'repro_context_evidence', ['context_pack_id']) + op.create_index('ix_repro_context_evidence_evidence_type', 'repro_context_evidence', ['evidence_type']) + + op.create_table( + 'repro_context_feedback', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('context_pack_id', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.String(length=64), nullable=False), + sa.Column('rating', sa.Integer(), nullable=True), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['context_pack_id'], ['repro_context_pack.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_repro_context_feedback_context_pack_id', 'repro_context_feedback', ['context_pack_id']) + op.create_index('ix_repro_context_feedback_user_id', 'repro_context_feedback', ['user_id']) + + +def downgrade() -> None: + op.drop_table('repro_context_feedback') + op.drop_table('repro_context_evidence') + op.drop_table('repro_context_stage_result') + op.drop_table('repro_context_pack') From 557a0d96f1930e2e9593b4f68a61b6fde5051e25 Mon Sep 17 00:00:00 2001 From: WenjingWang Date: Thu, 26 Feb 2026 22:32:50 +0100 Subject: [PATCH 2/2] feat(p2c): implement ReproContext API, port, store and studio UI. Related to #139 --- src/paperbot/api/main.py | 2 + src/paperbot/api/routes/repro_context.py | 270 ++++++++++++++++++ .../application/ports/repro_context_port.py | 74 +++++ src/paperbot/infrastructure/stores/models.py | 112 ++++++++ .../stores/repro_context_store.py | 194 +++++++++++++ web/src/app/studio/page.tsx | 2 +- 6 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 src/paperbot/api/routes/repro_context.py create mode 100644 src/paperbot/application/ports/repro_context_port.py create mode 100644 src/paperbot/infrastructure/stores/repro_context_store.py diff --git a/src/paperbot/api/main.py b/src/paperbot/api/main.py index fb63d64f..a328194d 100644 --- a/src/paperbot/api/main.py +++ b/src/paperbot/api/main.py @@ -24,6 +24,7 @@ harvest, model_endpoints, studio_chat, + repro_context, ) from paperbot.infrastructure.event_log.logging_event_log import LoggingEventLog from paperbot.infrastructure.event_log.composite_event_log import CompositeEventLog @@ -71,6 +72,7 @@ async def health_check(): app.include_router(harvest.router, prefix="/api", tags=["Harvest"]) app.include_router(model_endpoints.router, prefix="/api", tags=["Model Endpoints"]) app.include_router(studio_chat.router, prefix="/api", tags=["Studio Chat"]) +app.include_router(repro_context.router, prefix="/api/research/repro/context", tags=["P2C"]) @app.on_event("startup") diff --git a/src/paperbot/api/routes/repro_context.py b/src/paperbot/api/routes/repro_context.py new file mode 100644 index 00000000..7594bbc7 --- /dev/null +++ b/src/paperbot/api/routes/repro_context.py @@ -0,0 +1,270 @@ +""" +P2C (Paper-to-Context) API Route + +Endpoints: + POST /api/research/repro/context/generate — generate context pack (SSE) + GET /api/research/repro/context — list packs for a user + GET /api/research/repro/context/{pack_id} — get full pack detail + POST /api/research/repro/context/{pack_id}/session — create repro session from pack + DELETE /api/research/repro/context/{pack_id} — soft-delete pack +""" + +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import asdict as _asdict +from typing import Literal, Optional + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from paperbot.api.streaming import StreamEvent, wrap_generator +from paperbot.application.services.p2c.models import ( + GenerateContextRequest as P2CRequest, + new_context_pack_id, +) +from paperbot.application.services.p2c.orchestrator import ExtractionOrchestrator +from paperbot.infrastructure.stores.repro_context_store import SqlAlchemyReproContextStore + +router = APIRouter() + +_store = SqlAlchemyReproContextStore() + + +# ------------------------------------------------------------------ # +# Request / Response schemas # +# ------------------------------------------------------------------ # + +class GenerateContextPackRequest(BaseModel): + paper_id: str + user_id: str = "default" + project_id: Optional[str] = None + track_id: Optional[int] = None + depth: Literal["fast", "standard", "deep"] = "standard" + + +class CreateSessionRequest(BaseModel): + executor_preference: Literal["auto", "claude_code", "codex", "local"] = "auto" + override_env: Optional[dict] = None + override_roadmap: Optional[list] = None + + +# ------------------------------------------------------------------ # +# POST /generate (SSE) # +# ------------------------------------------------------------------ # + +async def _generate_stream(request: GenerateContextPackRequest): + """SSE generator for context pack generation via Module 1 ExtractionOrchestrator.""" + pack_id = new_context_pack_id() + + # Persist initial "running" record so the frontend can poll status. + _store.save( + pack_id=pack_id, + user_id=request.user_id, + paper_id=request.paper_id, + depth=request.depth, + pack_data={}, + project_id=request.project_id, + confidence_overall=0.0, + warning_count=0, + ) + + yield StreamEvent(type="status", data={"pack_id": pack_id, "status": "running"}) + + # asyncio.Queue bridges the on_stage_complete callback → SSE generator. + queue: asyncio.Queue = asyncio.Queue() + _DONE = object() + _ERROR = object() + + total_stages = 2 if request.depth == "fast" else 6 + stages_done = 0 + + async def on_stage_complete(stage_name: str, observations: list, warnings: list) -> None: + nonlocal stages_done + stages_done += 1 + confidence = ( + sum(o.confidence for o in observations) / len(observations) + if observations + else 0.0 + ) + _store.save_stage_result( + pack_id=pack_id, + stage_name=stage_name, + status="completed", + result_data={"observations": [o.to_full() for o in observations]}, + confidence=confidence, + duration_ms=0, + ) + await queue.put( + StreamEvent( + type="progress", + data={ + "stage": stage_name, + "progress": stages_done / total_stages, + "message": f"Completed {stage_name}", + }, + ) + ) + + p2c_request = P2CRequest( + paper_id=request.paper_id, + user_id=request.user_id, + project_id=request.project_id, + track_id=request.track_id, + depth=request.depth, + ) + orchestrator = ExtractionOrchestrator() + result_holder: list = [] + + async def _run() -> None: + try: + pack = await orchestrator.run(p2c_request, on_stage_complete=on_stage_complete) + result_holder.append(pack) + await queue.put(_DONE) + except Exception as exc: # noqa: BLE001 + result_holder.append(exc) + await queue.put(_ERROR) + + asyncio.create_task(_run()) + + # Drain queue, yielding progress events until the orchestrator finishes. + while True: + item = await queue.get() + if item is _DONE: + break + elif item is _ERROR: + _store.update_status(pack_id, status="failed") + yield StreamEvent(type="error", data={"message": str(result_holder[0])}) + return + else: + yield item # StreamEvent from on_stage_complete + + # Serialize and persist the final pack. + pack = result_holder[0] + pack_dict = _asdict(pack) + pack_dict["context_pack_id"] = pack_id # align with our DB record + + _store.update_status( + pack_id, + status="completed", + pack_data=pack_dict, + confidence_overall=pack.confidence.overall, + warning_count=len(pack.warnings), + objective=pack.objective, + ) + + yield StreamEvent( + type="result", + data={ + "context_pack_id": pack_id, + "status": "completed", + "summary": f"Context pack created for {request.paper_id}", + "confidence": _asdict(pack.confidence), + "warnings": pack.warnings, + "next_action": "create_repro_session", + }, + ) + + +@router.post("/generate") +async def generate_context_pack(request: GenerateContextPackRequest): + """Generate a P2C context pack for the given paper. Returns SSE stream.""" + return StreamingResponse( + wrap_generator( + _generate_stream(request), + workflow="p2c_generate", + ), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +# ------------------------------------------------------------------ # +# GET / (list) # +# ------------------------------------------------------------------ # + +@router.get("") +async def list_context_packs( + user_id: str = "default", + paper_id: Optional[str] = None, + project_id: Optional[str] = None, + limit: int = 20, + offset: int = 0, +): + """List context packs for a user, with optional filters.""" + items, total = _store.list_by_user( + user_id=user_id, + paper_id=paper_id, + project_id=project_id, + limit=limit, + offset=offset, + ) + return {"items": items, "total": total} + + +# ------------------------------------------------------------------ # +# GET /{pack_id} # +# ------------------------------------------------------------------ # + +@router.get("/{pack_id}") +async def get_context_pack(pack_id: str): + """Return the full context pack detail.""" + pack = _store.get(pack_id) + if pack is None: + raise HTTPException(status_code=404, detail="Context pack not found.") + return pack + + +# ------------------------------------------------------------------ # +# POST /{pack_id}/session # +# ------------------------------------------------------------------ # + +@router.post("/{pack_id}/session") +async def create_repro_session(pack_id: str, request: CreateSessionRequest): + """ + Convert a context pack into a runbook session. + + TODO: integrate with existing runbook creation once Module 1 is wired. + """ + pack = _store.get(pack_id) + if pack is None: + raise HTTPException(status_code=404, detail="Context pack not found.") + + session_id = f"sess_{uuid.uuid4().hex[:16]}" + runbook_id = f"rb_{uuid.uuid4().hex[:12]}" + + roadmap = pack.get("pack", {}).get("task_roadmap", []) + initial_steps = [ + { + "step_id": f"S{i + 1}", + "title": cp.get("title", f"Step {i + 1}"), + "command": "", + "status": "pending", + } + for i, cp in enumerate(roadmap) + ] or [{"step_id": "S1", "title": "Setup environment", "command": "", "status": "pending"}] + + return { + "session_id": session_id, + "runbook_id": runbook_id, + "initial_steps": initial_steps, + "initial_prompt": ( + f"Based on the reproduction context pack for paper {pack.get('paper_id', '')}, " + "implement the code step by step following the roadmap." + ), + } + + +# ------------------------------------------------------------------ # +# DELETE /{pack_id} # +# ------------------------------------------------------------------ # + +@router.delete("/{pack_id}") +async def delete_context_pack(pack_id: str): + """Soft-delete a context pack.""" + deleted = _store.soft_delete(pack_id) + if not deleted: + raise HTTPException(status_code=404, detail="Context pack not found.") + return {"status": "deleted", "context_pack_id": pack_id} diff --git a/src/paperbot/application/ports/repro_context_port.py b/src/paperbot/application/ports/repro_context_port.py new file mode 100644 index 00000000..33560c0d --- /dev/null +++ b/src/paperbot/application/ports/repro_context_port.py @@ -0,0 +1,74 @@ +"""ReproContextPort — interface for P2C context pack persistence.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable + + +@runtime_checkable +class ReproContextPort(Protocol): + """Abstract interface for ReproContextPack read/write operations.""" + + def save( + self, + *, + pack_id: str, + user_id: str, + paper_id: str, + depth: str, + pack_data: Dict[str, Any], + paper_title: Optional[str] = None, + project_id: Optional[str] = None, + objective: Optional[str] = None, + confidence_overall: float = 0.0, + warning_count: int = 0, + ) -> str: + """Persist a new context pack. Returns the pack_id.""" + ... + + def update_status( + self, + pack_id: str, + *, + status: str, + pack_data: Optional[Dict[str, Any]] = None, + confidence_overall: Optional[float] = None, + warning_count: Optional[int] = None, + objective: Optional[str] = None, + ) -> None: + """Update status (and optionally pack_json) of an existing pack.""" + ... + + def get(self, pack_id: str) -> Optional[Dict[str, Any]]: + """Return the full context pack dict, or None if not found / soft-deleted.""" + ... + + def list_by_user( + self, + *, + user_id: str, + paper_id: Optional[str] = None, + project_id: Optional[str] = None, + limit: int = 20, + offset: int = 0, + ) -> Tuple[List[Dict[str, Any]], int]: + """Return (summary_list, total_count) for the given user.""" + ... + + def soft_delete(self, pack_id: str) -> bool: + """Mark a pack as deleted. Returns True if the row was found and updated.""" + ... + + def save_stage_result( + self, + *, + pack_id: str, + stage_name: str, + status: str, + result_data: Optional[Dict[str, Any]] = None, + confidence: float = 0.0, + duration_ms: int = 0, + error_message: Optional[str] = None, + ) -> None: + """Persist an intermediate stage result for debugging / audit.""" + ... diff --git a/src/paperbot/infrastructure/stores/models.py b/src/paperbot/infrastructure/stores/models.py index 915d474f..aa0e51b3 100644 --- a/src/paperbot/infrastructure/stores/models.py +++ b/src/paperbot/infrastructure/stores/models.py @@ -1143,3 +1143,115 @@ def get_errors(self) -> dict: def set_errors(self, errors: dict) -> None: self.error_json = json.dumps(errors or {}, ensure_ascii=False) + + +# ============================================================================ +# P2C (Paper-to-Context) Module 2 Models +# ============================================================================ + + +class ReproContextPackModel(Base): + """P2C context pack: stores the full ReproContextPack JSON produced by Core Engine.""" + + __tablename__ = "repro_context_pack" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) # "ctxp_{uuid}" + user_id: Mapped[str] = mapped_column(String(64), nullable=False, default="default", index=True) + project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + paper_id: Mapped[str] = mapped_column(String(256), nullable=False, index=True) + paper_title: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + version: Mapped[str] = mapped_column(String(16), nullable=False, default="v1") + depth: Mapped[str] = mapped_column(String(16), nullable=False, default="standard") # fast/standard/deep + status: Mapped[str] = mapped_column( + String(32), nullable=False, default="pending", index=True + ) # pending/running/completed/failed + objective: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + pack_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + confidence_overall: Mapped[float] = mapped_column(Float, default=0.0, index=True) + warning_count: Mapped[int] = mapped_column(Integer, default=0) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + stage_results = relationship( + "ReproContextStageResultModel", back_populates="pack", cascade="all, delete-orphan" + ) + evidence_links = relationship( + "ReproContextEvidenceModel", back_populates="pack", cascade="all, delete-orphan" + ) + feedback_rows = relationship( + "ReproContextFeedbackModel", back_populates="pack", cascade="all, delete-orphan" + ) + + def get_pack(self) -> dict: + try: + return json.loads(self.pack_json or "{}") + except Exception: + return {} + + def set_pack(self, data: dict) -> None: + self.pack_json = json.dumps(data or {}, ensure_ascii=False) + + +class ReproContextStageResultModel(Base): + """Per-stage intermediate result for debugging and audit.""" + + __tablename__ = "repro_context_stage_result" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + context_pack_id: Mapped[str] = mapped_column( + String(64), ForeignKey("repro_context_pack.id", ondelete="CASCADE"), index=True + ) + stage_name: Mapped[str] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(16), default="completed", index=True) # completed/failed/skipped + result_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + confidence: Mapped[float] = mapped_column(Float, default=0.0) + duration_ms: Mapped[int] = mapped_column(Integer, default=0) + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + pack = relationship("ReproContextPackModel", back_populates="stage_results") + + +class ReproContextEvidenceModel(Base): + """Evidence links for audit trail — maps extracted fields back to paper spans.""" + + __tablename__ = "repro_context_evidence" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + context_pack_id: Mapped[str] = mapped_column( + String(64), ForeignKey("repro_context_pack.id", ondelete="CASCADE"), index=True + ) + evidence_type: Mapped[str] = mapped_column(String(32), index=True) # paper_span/table/figure/code_snippet/metadata + ref: Mapped[str] = mapped_column(Text, default="") + supports_json: Mapped[str] = mapped_column(Text, default="[]") # JSON array of field names + confidence: Mapped[float] = mapped_column(Float, default=0.0) + + pack = relationship("ReproContextPackModel", back_populates="evidence_links") + + def get_supports(self) -> list: + try: + return json.loads(self.supports_json or "[]") + except Exception: + return [] + + def set_supports(self, values: list) -> None: + self.supports_json = json.dumps(values or [], ensure_ascii=False) + + +class ReproContextFeedbackModel(Base): + """User ratings and comments on a context pack (collected post-launch).""" + + __tablename__ = "repro_context_feedback" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + context_pack_id: Mapped[str] = mapped_column( + String(64), ForeignKey("repro_context_pack.id", ondelete="CASCADE"), index=True + ) + user_id: Mapped[str] = mapped_column(String(64), index=True) + rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 1-5 + comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) + + pack = relationship("ReproContextPackModel", back_populates="feedback_rows") diff --git a/src/paperbot/infrastructure/stores/repro_context_store.py b/src/paperbot/infrastructure/stores/repro_context_store.py new file mode 100644 index 00000000..b27db27f --- /dev/null +++ b/src/paperbot/infrastructure/stores/repro_context_store.py @@ -0,0 +1,194 @@ +"""SqlAlchemyReproContextStore — P2C context pack persistence.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import select, func + +from paperbot.infrastructure.stores.models import ReproContextPackModel, ReproContextStageResultModel +from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class SqlAlchemyReproContextStore: + """SQLAlchemy implementation of ReproContextPort.""" + + def __init__(self, db_url: Optional[str] = None): + self._provider = SessionProvider(db_url) + + # ------------------------------------------------------------------ # + # Write # + # ------------------------------------------------------------------ # + + def save( + self, + *, + pack_id: str, + user_id: str, + paper_id: str, + depth: str, + pack_data: Dict[str, Any], + paper_title: Optional[str] = None, + project_id: Optional[str] = None, + objective: Optional[str] = None, + confidence_overall: float = 0.0, + warning_count: int = 0, + ) -> str: + now = _utcnow() + row = ReproContextPackModel( + id=pack_id, + user_id=user_id, + project_id=project_id, + paper_id=paper_id, + paper_title=paper_title, + depth=depth, + status="running", + objective=objective, + confidence_overall=confidence_overall, + warning_count=warning_count, + created_at=now, + updated_at=now, + ) + row.set_pack(pack_data) + with self._provider.session() as session: + session.add(row) + session.commit() + return pack_id + + def update_status( + self, + pack_id: str, + *, + status: str, + pack_data: Optional[Dict[str, Any]] = None, + confidence_overall: Optional[float] = None, + warning_count: Optional[int] = None, + objective: Optional[str] = None, + ) -> None: + with self._provider.session() as session: + row = session.get(ReproContextPackModel, pack_id) + if row is None: + return + row.status = status + row.updated_at = _utcnow() + if pack_data is not None: + row.set_pack(pack_data) + if confidence_overall is not None: + row.confidence_overall = confidence_overall + if warning_count is not None: + row.warning_count = warning_count + if objective is not None: + row.objective = objective + session.commit() + + def soft_delete(self, pack_id: str) -> bool: + with self._provider.session() as session: + row = session.get(ReproContextPackModel, pack_id) + if row is None or row.deleted_at is not None: + return False + row.deleted_at = _utcnow() + row.updated_at = _utcnow() + session.commit() + return True + + def save_stage_result( + self, + *, + pack_id: str, + stage_name: str, + status: str, + result_data: Optional[Dict[str, Any]] = None, + confidence: float = 0.0, + duration_ms: int = 0, + error_message: Optional[str] = None, + ) -> None: + now = _utcnow() + row = ReproContextStageResultModel( + context_pack_id=pack_id, + stage_name=stage_name, + status=status, + result_json=json.dumps(result_data or {}, ensure_ascii=False), + confidence=confidence, + duration_ms=duration_ms, + error_message=error_message, + created_at=now, + ) + with self._provider.session() as session: + session.add(row) + session.commit() + + # ------------------------------------------------------------------ # + # Read # + # ------------------------------------------------------------------ # + + def get(self, pack_id: str) -> Optional[Dict[str, Any]]: + with self._provider.session() as session: + row = session.get(ReproContextPackModel, pack_id) + if row is None or row.deleted_at is not None: + return None + return self._row_to_full_dict(row) + + def list_by_user( + self, + *, + user_id: str, + paper_id: Optional[str] = None, + project_id: Optional[str] = None, + limit: int = 20, + offset: int = 0, + ) -> Tuple[List[Dict[str, Any]], int]: + with self._provider.session() as session: + base = ( + select(ReproContextPackModel) + .where( + ReproContextPackModel.user_id == user_id, + ReproContextPackModel.deleted_at.is_(None), + ) + .order_by(ReproContextPackModel.created_at.desc()) + ) + if paper_id: + base = base.where(ReproContextPackModel.paper_id == paper_id) + if project_id: + base = base.where(ReproContextPackModel.project_id == project_id) + + total = session.scalar( + select(func.count()).select_from(base.subquery()) + ) or 0 + + rows = session.scalars(base.limit(limit).offset(offset)).all() + summaries = [self._row_to_summary_dict(r) for r in rows] + return summaries, total + + # ------------------------------------------------------------------ # + # Helpers # + # ------------------------------------------------------------------ # + + def _row_to_summary_dict(self, row: ReproContextPackModel) -> Dict[str, Any]: + return { + "context_pack_id": row.id, + "paper_id": row.paper_id, + "paper_title": row.paper_title, + "depth": row.depth, + "status": row.status, + "confidence_overall": row.confidence_overall, + "warning_count": row.warning_count, + "created_at": row.created_at.isoformat() if row.created_at else None, + } + + def _row_to_full_dict(self, row: ReproContextPackModel) -> Dict[str, Any]: + base = self._row_to_summary_dict(row) + base.update({ + "user_id": row.user_id, + "project_id": row.project_id, + "objective": row.objective, + "version": row.version, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "pack": row.get_pack(), + }) + return base diff --git a/web/src/app/studio/page.tsx b/web/src/app/studio/page.tsx index f2dfd2f3..282d588b 100644 --- a/web/src/app/studio/page.tsx +++ b/web/src/app/studio/page.tsx @@ -52,7 +52,7 @@ function StudioContent() { }, [searchParams, papers]) return ( -
+
{/* Top Bar - minimal */}