diff --git a/backend/alembic/versions/2026_04_04_2000_b3c4d5e6f7a8_add_assumptions_to_commits.py b/backend/alembic/versions/2026_04_04_2000_b3c4d5e6f7a8_add_assumptions_to_commits.py new file mode 100644 index 0000000..2186f45 --- /dev/null +++ b/backend/alembic/versions/2026_04_04_2000_b3c4d5e6f7a8_add_assumptions_to_commits.py @@ -0,0 +1,30 @@ +"""add_assumptions_to_commits + +Revision ID: b3c4d5e6f7a8 +Revises: a1b2c3d4e5f6 +Create Date: 2026-04-04 20:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = 'b3c4d5e6f7a8' +down_revision: Union[str, None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + 'commits', + sa.Column('assumptions', JSONB(), nullable=False, server_default='[]'), + ) + + +def downgrade() -> None: + op.drop_column('commits', 'assumptions') diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index a0b0fb3..7ee1210 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -144,6 +144,11 @@ def build_prompt_from_checkpoints(checkpoints: list[CommitModel], recent_message for d in ckpt.decisions: lines.append(f"- {d}") lines.append("") + if ckpt.assumptions: + lines.append(f"{label} Key Assumptions:") + for a in ckpt.assumptions: + lines.append(f"- {a}") + lines.append("") if ckpt.tasks: lines.append(f"{label} Open Tasks:") for t in ckpt.tasks: @@ -251,6 +256,7 @@ class ManualCommitRequest(BaseModel): summary: str = "" objective: str = "" decisions: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) tasks: list[str] = Field(default_factory=list) open_questions: list[str] = Field(default_factory=list) entities: list[str] = Field(default_factory=list) @@ -266,6 +272,7 @@ class CommitResponse(BaseModel): summary: str objective: str decisions: list + assumptions: list tasks: list open_questions: list entities: list @@ -622,6 +629,7 @@ def manual_commit(payload: ManualCommitRequest, db: Session = Depends(get_db)): summary=payload.summary, objective=payload.objective, decisions=payload.decisions, + assumptions=payload.assumptions, tasks=payload.tasks, open_questions=payload.open_questions, entities=payload.entities, diff --git a/backend/app/api/routes/checkpoint.py b/backend/app/api/routes/checkpoint.py index 80415d0..f928d68 100644 --- a/backend/app/api/routes/checkpoint.py +++ b/backend/app/api/routes/checkpoint.py @@ -1,4 +1,4 @@ -"""Checkpoint routes for auto drafting.""" +"""Checkpoint routes for auto drafting and review.""" import json import logging @@ -10,8 +10,8 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.db.database import get_db -from app.db.models import ChatSession, TurnEvent -from app.schemas import CheckpointDraftRequest, CheckpointDraftResponse +from app.db.models import ChatSession, CommitModel, TurnEvent +from app.schemas import CheckpointDraftRequest, CheckpointDraftResponse, CheckpointReviewResponse, ReviewIssue from app.providers.registry import get_adapter from app.config_loader import get_config @@ -107,6 +107,7 @@ Return a STRICT JSON object with exactly this schema — no extra keys, no markd "objective": "The main goal the user is working toward in this conversation (1 sentence, or empty string if unclear)", "summary": "Concise narrative of what was discussed and figured out (2-4 sentences)", "decisions": ["An explicit decision made in the conversation", "Another explicit decision"], + "assumptions": ["Something taken for granted but not explicitly decided"], "tasks": ["A concrete action item from the conversation", "Another action item"], "open_questions": ["An unresolved question from the conversation"], "entities": ["Key concept, tool, place, or system mentioned"] @@ -114,6 +115,7 @@ Return a STRICT JSON object with exactly this schema — no extra keys, no markd Rules: - decisions: only include choices explicitly made in the conversation, not hypothetical ones +- assumptions: things the conversation takes for granted that were NOT explicitly debated or decided (e.g., implicit constraints, assumed technology choices, timeline expectations treated as given) - tasks: only include things the user said they will do or need to do - entities: proper nouns and key technical/domain terms only - All arrays may be empty if nothing relevant was discussed @@ -138,14 +140,17 @@ Rules: raw_response = adapter.send(messages, model=bg_model, response_format={"type": "json_object"}) data = json.loads(raw_response) + _dedup = lambda items: list(dict.fromkeys([str(x).strip() for x in items if x])) + return CheckpointDraftResponse( title=str(data.get("title", "")).strip(), objective=str(data.get("objective", "")).strip(), summary=str(data.get("summary", "")).strip(), - decisions=list(dict.fromkeys([str(x).strip() for x in data.get("decisions", []) if x])), - tasks=list(dict.fromkeys([str(x).strip() for x in data.get("tasks", []) if x])), - open_questions=list(dict.fromkeys([str(x).strip() for x in data.get("open_questions", []) if x])), - entities=list(dict.fromkeys([str(x).strip() for x in data.get("entities", []) if x])), + decisions=_dedup(data.get("decisions", [])), + assumptions=_dedup(data.get("assumptions", [])), + tasks=_dedup(data.get("tasks", [])), + open_questions=_dedup(data.get("open_questions", [])), + entities=_dedup(data.get("entities", [])), ) except json.JSONDecodeError: @@ -154,3 +159,114 @@ Rules: except Exception as e: logger.error(f"LLM extraction error: {e}") raise HTTPException(status_code=502, detail=f"Drafting failed: {e}") + + +# ── Review endpoint ────────────────────────────────────────────────────────── + +def _format_bullet_list(items: list, fallback: str = "None listed") -> str: + if not items: + return fallback + return "\n".join(f"- {item}" for item in items) + + +@router.post("/{checkpoint_id}/review", response_model=CheckpointReviewResponse) +def review_checkpoint(checkpoint_id: uuid.UUID, db: Session = Depends(get_db)): + """ + Review a checkpoint for reasoning consistency. + + Sends the checkpoint's structured fields to the background intelligence + provider and returns a small number of high-signal issues. + """ + commit = db.get(CommitModel, checkpoint_id) + if not commit: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + prompt = f"""You are a reasoning consistency reviewer. +Review this structured checkpoint that captures the state of a reasoning process. + +CHECKPOINT: +Title: {commit.message} +Objective: {commit.objective or "(not set)"} +Summary: {commit.summary or "(not set)"} + +Assumptions: +{_format_bullet_list(commit.assumptions or [])} + +Decisions: +{_format_bullet_list(commit.decisions or [])} + +Tasks: +{_format_bullet_list(commit.tasks or [])} + +Open Questions: +{_format_bullet_list(commit.open_questions or [])} + +Entities: +{", ".join(commit.entities or []) or "None listed"} + +Identify ONLY these issue types: + +1. CONTRADICTION: Two decisions, or a decision and an assumption, that appear to conflict with each other. +2. HIDDEN_ASSUMPTION: Something the summary or decisions clearly rely on that is not listed as an assumption or decision. Only flag when the implicit reliance is obvious. +3. RESOLVED_QUESTION: An open question that appears already answered by a decision or the summary. +4. UNUSED_ENTITY: An entity that is not referenced in the summary, decisions, tasks, or objective. + +Rules: +- Be conservative. Only flag issues you are confident about. +- Prefer precision over recall — it is better to miss an issue than to flag a false one. +- Reference specific text from the checkpoint in each description. +- Return at most 5 issues. +- Suggestions should be brief and actionable. +- If no issues are found, return empty arrays. + +Return STRICT JSON — no markdown, no explanation: +{{ + "issues": [ + {{ + "type": "contradiction | hidden_assumption | resolved_question | unused_entity", + "description": "Brief description referencing specific checkpoint text" + }} + ], + "suggestions": ["Brief actionable suggestion"] +}}""" + + try: + cfg = get_config() + bg_provider = cfg.background.provider + bg_model = cfg.background.model + adapter = get_adapter(bg_provider, allow_mock=False) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Background provider not configured. Error: {e}" + ) + + try: + raw_response = adapter.send( + [{"role": "user", "content": prompt}], + model=bg_model, + response_format={"type": "json_object"}, + ) + data = json.loads(raw_response) + + issues = [] + for item in data.get("issues", []): + issue_type = str(item.get("type", "")).strip() + desc = str(item.get("description", "")).strip() + if issue_type and desc: + issues.append(ReviewIssue(type=issue_type, description=desc)) + + suggestions = [str(s).strip() for s in data.get("suggestions", []) if s] + + return CheckpointReviewResponse( + checkpoint_id=checkpoint_id, + issues=issues[:5], + suggestions=suggestions[:5], + ) + + except json.JSONDecodeError: + logger.error(f"Review returned invalid JSON: {raw_response}") + raise HTTPException(status_code=502, detail="Failed to parse review response.") + except Exception as e: + logger.error(f"Review error: {e}") + raise HTTPException(status_code=502, detail=f"Review failed: {e}") diff --git a/backend/app/api/routes/lineage.py b/backend/app/api/routes/lineage.py index 76e251b..f5b7e9a 100644 --- a/backend/app/api/routes/lineage.py +++ b/backend/app/api/routes/lineage.py @@ -108,6 +108,7 @@ class CheckpointDetail(BaseModel): summary: str objective: str decisions: list + assumptions: list tasks: list open_questions: list @@ -120,6 +121,9 @@ class CheckpointDiff(BaseModel): decisions_only_a: list[str] decisions_only_b: list[str] decisions_shared: list[str] + assumptions_only_a: list[str] = Field(default_factory=list) + assumptions_only_b: list[str] = Field(default_factory=list) + assumptions_shared: list[str] = Field(default_factory=list) tasks_only_a: list[str] tasks_only_b: list[str] tasks_shared: list[str] @@ -145,6 +149,7 @@ class ReachableCheckpoint(BaseModel): summary: str objective: str decisions: list + assumptions: list tasks: list open_questions: list entities: list @@ -301,6 +306,9 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends( dec_only_a, dec_only_b, dec_shared = _diff_lists( commit_a.decisions or [], commit_b.decisions or [] ) + assump_only_a, assump_only_b, assump_shared = _diff_lists( + commit_a.assumptions or [], commit_b.assumptions or [] + ) task_only_a, task_only_b, task_shared = _diff_lists( commit_a.tasks or [], commit_b.tasks or [] ) @@ -314,6 +322,7 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends( summary=c.summary or "", objective=c.objective or "", decisions=[_extract_text(d) for d in (c.decisions or [])], + assumptions=[_extract_text(a) for a in (c.assumptions or [])], tasks=[_extract_text(t) for t in (c.tasks or [])], open_questions=[_extract_text(q) for q in (c.open_questions or [])], ) @@ -329,6 +338,9 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends( decisions_only_a=dec_only_a, decisions_only_b=dec_only_b, decisions_shared=dec_shared, + assumptions_only_a=assump_only_a, + assumptions_only_b=assump_only_b, + assumptions_shared=assump_shared, tasks_only_a=task_only_a, tasks_only_b=task_only_b, tasks_shared=task_shared, diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 13c63fc..e8dce02 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -169,6 +169,7 @@ class CommitModel(Base): summary: Mapped[str] = mapped_column(Text, default="") objective: Mapped[str] = mapped_column(Text, default="") decisions: Mapped[dict] = mapped_column(JSONB, default=list) + assumptions: Mapped[dict] = mapped_column(JSONB, default=list) tasks: Mapped[dict] = mapped_column(JSONB, default=list) open_questions: Mapped[dict] = mapped_column(JSONB, default=list) entities: Mapped[dict] = mapped_column(JSONB, default=list) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index dbe637d..908bc20 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -110,6 +110,18 @@ class CheckpointDraftResponse(BaseModel): objective: str = "" summary: str = "" decisions: list[str] = Field(default_factory=list) + assumptions: list[str] = Field(default_factory=list) tasks: list[str] = Field(default_factory=list) open_questions: list[str] = Field(default_factory=list) entities: list[str] = Field(default_factory=list) + + +class ReviewIssue(BaseModel): + type: str # contradiction, hidden_assumption, resolved_question, unused_entity + description: str + + +class CheckpointReviewResponse(BaseModel): + checkpoint_id: uuid.UUID + issues: list[ReviewIssue] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8bdf38e..64a6e30 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -250,6 +250,7 @@ export async function createChatCommit(payload: { summary?: string; objective?: string; decisions?: string[]; + assumptions?: string[]; tasks?: string[]; open_questions?: string[]; entities?: string[]; @@ -322,6 +323,7 @@ export async function draftCheckpoint(payload: { objective: string; summary: string; decisions: string[]; + assumptions: string[]; tasks: string[]; open_questions: string[]; entities: string[]; @@ -331,6 +333,7 @@ export async function draftCheckpoint(payload: { objective: string; summary: string; decisions: string[]; + assumptions: string[]; tasks: string[]; open_questions: string[]; entities: string[]; @@ -354,3 +357,9 @@ export async function getSessionCheckpoints(sessionId: string): Promise(`/lineage/sessions/${sessionId}/checkpoints`); } +export async function reviewCheckpoint(checkpointId: string): Promise { + return requestV5(`/checkpoint/${checkpointId}/review`, { + method: 'POST', + }); +} + diff --git a/frontend/src/pages/ChatWorkspacePage.tsx b/frontend/src/pages/ChatWorkspacePage.tsx index a8667b2..d10adf5 100644 --- a/frontend/src/pages/ChatWorkspacePage.tsx +++ b/frontend/src/pages/ChatWorkspacePage.tsx @@ -20,8 +20,9 @@ import { forkSession, compareCheckpoints, getSessionCheckpoints, + reviewCheckpoint, } from '../api/client'; -import type { ChatSession, Commit, CompareResponse, HeadState, TurnEvent, Repo, ProviderStatus } from '../types'; +import type { ChatSession, Commit, CompareResponse, CheckpointReviewResponse, HeadState, TurnEvent, Repo, ProviderStatus } from '../types'; import { Check, Copy, @@ -239,6 +240,7 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus, const [obj, setObj] = useState(''); const [tasks, setTasks] = useState(''); const [decisions, setDecs] = useState(''); + const [assumptions, setAssumptions] = useState(''); const [openQuestions, setOpenQuestions] = useState(''); const [entities, setEntities] = useState(''); const [loading, setLoading] = useState(false); @@ -268,6 +270,7 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus, setSummary(draft.summary || ''); setTasks((draft.tasks ?? []).join('\n')); setDecs((draft.decisions ?? []).join('\n')); + setAssumptions((draft.assumptions ?? []).join('\n')); setOpenQuestions((draft.open_questions ?? []).join('\n')); setEntities((draft.entities ?? []).join('\n')); } catch (e: any) { @@ -290,6 +293,7 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus, objective: obj.trim(), tasks: parseLines(tasks), decisions: parseLines(decisions), + assumptions: parseLines(assumptions), open_questions: parseLines(openQuestions), entities: parseLines(entities), }); @@ -368,18 +372,6 @@ function CommitModal({ repoId, sessionId, onClose, onCommitted, providerStatus,
-
- -