Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/status/db/confirm.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,37 @@ def latest_confirmed_week(session: Session, person_id: str) -> date | None:
return session.scalars(stmt).first()


def record_regeneration(
session: Session,
person_id: str,
week_ending: date,
*,
reason: str,
notes: str | None = None,
) -> Participation:
"""Record that the user regenerated their draft for this week."""
now = datetime.now(timezone.utc)
row = session.get(Participation, (person_id, week_ending))
if row is None:
row = Participation(
person_id=person_id,
week_ending=week_ending,
status="sent",
draft_sent_at=now,
)
session.add(row)

row.regenerated = True
reason_text = reason
if notes and notes.strip():
reason_text = f"{reason}: {notes.strip()}"
row.regenerate_reason = reason_text
if notes and notes.strip():
row.note = notes.strip()
session.flush()
return row


def record_draft_sent(session: Session, person_id: str, week_ending: date) -> Participation:
now = datetime.now(timezone.utc)
row = session.get(Participation, (person_id, week_ending))
Expand Down
163 changes: 0 additions & 163 deletions src/status/db/draft.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,166 +301,3 @@ def get_current_drafts(session: Session, person_id: str, week_ending: date) -> l
StatusEntry.confirmed_at.is_(None),
)
return list(session.scalars(stmt).all())


def persist_edited_entries(
session: Session,
person_id: str,
week_ending: date,
*,
edited_outcomes: dict[int, str], # index -> new outcome
dropped_indices: set[int],
unticketed_work: str | None,
leadership_asks: str | None,
) -> list[StatusEntry]:
"""Create new 'drafted_edited' revisions for changed entries.

Args:
session: Database session
person_id: Person ID
week_ending: Week ending date
edited_outcomes: Map of entry index to new outcome text
dropped_indices: Set of indices to remove
unticketed_work: Optional unticketed work description
leadership_asks: Optional leadership asks

Returns:
List of newly created/updated entries
"""
# Get current drafts in order - access attributes BEFORE we supersede them
current_entries = get_current_drafts(session, person_id, week_ending)
if not current_entries:
return []

# Extract all data we need from current entries BEFORE modifying them
entry_data = []
for entry in current_entries:
entry_data.append({
'epic_key': entry.epic_key,
'epic_name_snapshot': entry.epic_name_snapshot,
'project': entry.project,
'state': entry.state,
'outcome': entry.outcome,
'blocker': entry.blocker,
'ask': entry.ask,
'draft_outcome': entry.draft_outcome,
'confidence': entry.confidence,
'prompt_version': entry.prompt_version,
'evidence': entry.evidence,
'extra': entry.extra,
'revision': entry.revision,
'entry_id': entry.entry_id,
'drafted_at': entry.drafted_at,
})

# Supersede all current drafts using UPDATE for immediate database-level change
session.execute(
update(StatusEntry)
.where(
StatusEntry.person_id == person_id,
StatusEntry.week_ending == week_ending,
StatusEntry.is_current.is_(True),
StatusEntry.confirmed_at.is_(None),
)
.values(is_current=False)
)
# Flush immediately to ensure partial unique index is updated before inserts
session.flush()

edited_at = datetime.now(timezone.utc)
new_entries: list[StatusEntry] = []

# Process each entry using the extracted data
for idx, data in enumerate(entry_data):
# Skip dropped entries
if idx in dropped_indices:
continue

# Check if outcome was edited
new_outcome = edited_outcomes.get(idx)
if new_outcome and new_outcome.strip() != data['outcome'].strip():
# Create edited revision
new_entry = StatusEntry(
week_ending=week_ending,
person_id=person_id,
epic_key=data['epic_key'],
epic_name_snapshot=data['epic_name_snapshot'],
project=data['project'],
state=data['state'],
outcome=new_outcome.strip(),
blocker=data['blocker'],
ask=leadership_asks if leadership_asks and leadership_asks.strip() else data['ask'],
draft_outcome=data['draft_outcome'], # Preserve original draft
source=EntrySource.DRAFTED_EDITED.value,
confidence=data['confidence'],
needs_human=False, # Human just reviewed it
prompt_version=data['prompt_version'],
evidence=data['evidence'],
extra=data['extra'],
revision=data['revision'] + 1,
supersedes_entry_id=data['entry_id'],
is_current=True,
drafted_at=data['drafted_at'],
confirmed_at=None,
)
session.add(new_entry)
new_entries.append(new_entry)
else:
# No change, keep original entry as current but update asks if provided
# Re-create the entry with is_current=True
unchanged_entry = StatusEntry(
week_ending=week_ending,
person_id=person_id,
epic_key=data['epic_key'],
epic_name_snapshot=data['epic_name_snapshot'],
project=data['project'],
state=data['state'],
outcome=data['outcome'],
blocker=data['blocker'],
ask=leadership_asks.strip() if leadership_asks and leadership_asks.strip() else data['ask'],
draft_outcome=data['draft_outcome'],
source=EntrySource.DRAFTED.value,
confidence=data['confidence'],
needs_human=False,
prompt_version=data['prompt_version'],
evidence=data['evidence'],
extra=data['extra'],
revision=data['revision'],
supersedes_entry_id=None,
is_current=True,
drafted_at=data['drafted_at'],
confirmed_at=None,
)
session.add(unchanged_entry)
new_entries.append(unchanged_entry)

# Add unticketed work as a new entry if provided
if unticketed_work and unticketed_work.strip():
unticketed_entry = StatusEntry(
week_ending=week_ending,
person_id=person_id,
epic_key=None,
epic_name_snapshot=None,
project="Unticketed",
state="progressing",
outcome=unticketed_work.strip(),
blocker=None,
ask=None,
draft_outcome=None,
source=EntrySource.HUMAN_WRITTEN.value,
confidence="high",
needs_human=False,
prompt_version="manual",
evidence=[],
extra={},
revision=1,
supersedes_entry_id=None,
is_current=True,
drafted_at=edited_at,
confirmed_at=None,
)
session.add(unticketed_entry)
new_entries.append(unticketed_entry)

session.flush()
return new_entries
152 changes: 152 additions & 0 deletions src/status/db/edit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Ledger revisions for human-edited draft entries."""

from __future__ import annotations

from datetime import date, datetime, timezone
from sqlalchemy.orm import Session

from status.db.draft import get_current_drafts
from status.db.models import EntrySource, StatusEntry


class EditValidationError(ValueError):
"""Raised when edit payload would violate ledger constraints."""


def _supersede_entry(session: Session, entry: StatusEntry) -> None:
if not entry.is_current:
return
entry.is_current = False
session.flush()


def _clone_edited_entry(
entry: StatusEntry,
*,
outcome: str,
ask: str | None,
source: str,
) -> StatusEntry:
return StatusEntry(
week_ending=entry.week_ending,
person_id=entry.person_id,
epic_key=entry.epic_key,
epic_name_snapshot=entry.epic_name_snapshot,
project=entry.project,
state=entry.state,
outcome=outcome,
blocker=entry.blocker,
ask=ask,
draft_outcome=entry.draft_outcome,
source=source,
confidence=entry.confidence,
needs_human=False,
prompt_version=entry.prompt_version,
evidence=entry.evidence,
extra=entry.extra,
revision=entry.revision + 1,
supersedes_entry_id=entry.entry_id,
is_current=True,
drafted_at=entry.drafted_at,
confirmed_at=None,
)


def persist_edited_entries(
session: Session,
person_id: str,
week_ending: date,
*,
edited_outcomes: dict[str, str],
unticketed_work: str | None,
existing_unticketed_entry_id: str | None,
) -> list[StatusEntry]:
"""Apply per-entry edits without touching unchanged current rows."""
current_entries = get_current_drafts(session, person_id, week_ending)
if not current_entries:
return []

by_id = {str(entry.entry_id): entry for entry in current_entries}
new_entries: list[StatusEntry] = []

for entry_id, raw_outcome in edited_outcomes.items():
entry = by_id.get(entry_id)
if entry is None or not entry.is_current:
continue

outcome = raw_outcome.strip()
if not outcome:
_supersede_entry(session, entry)
continue

if outcome == entry.outcome.strip():
continue

_supersede_entry(session, entry)
new_entry = _clone_edited_entry(
entry,
outcome=outcome,
ask=entry.ask,
source=EntrySource.DRAFTED_EDITED.value,
)
session.add(new_entry)
new_entries.append(new_entry)

unticketed_text = unticketed_work.strip() if unticketed_work else ""
existing_unticketed: StatusEntry | None = None
if existing_unticketed_entry_id:
candidate = by_id.get(existing_unticketed_entry_id)
if candidate is not None and candidate.epic_key is None:
existing_unticketed = candidate

if existing_unticketed is not None:
if not unticketed_text:
if existing_unticketed.is_current:
_supersede_entry(session, existing_unticketed)
elif unticketed_text != existing_unticketed.outcome.strip():
if existing_unticketed.is_current:
_supersede_entry(session, existing_unticketed)
new_entries.append(
_add_unticketed_entry(session, person_id, week_ending, unticketed_text)
)
elif unticketed_text:
new_entries.append(_add_unticketed_entry(session, person_id, week_ending, unticketed_text))

session.flush()
return new_entries


def _add_unticketed_entry(
session: Session,
person_id: str,
week_ending: date,
outcome: str,
) -> StatusEntry:
if not outcome.strip():
raise EditValidationError("unticketed work outcome cannot be blank")
edited_at = datetime.now(timezone.utc)
row = StatusEntry(
week_ending=week_ending,
person_id=person_id,
epic_key=None,
epic_name_snapshot=None,
project="Unticketed",
state="progressing",
outcome=outcome.strip(),
blocker=None,
ask=None,
draft_outcome=None,
source=EntrySource.HUMAN_WRITTEN.value,
confidence="high",
needs_human=False,
prompt_version="manual",
evidence=[],
extra={},
revision=1,
supersedes_entry_id=None,
is_current=True,
drafted_at=edited_at,
confirmed_at=None,
)
session.add(row)
return row
10 changes: 9 additions & 1 deletion src/status/skills/drafter.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,18 @@ def run_drafter(payload: dict[str, Any], *, dry_run: bool = False) -> DraftOutpu
version=settings.drafter_skill_version,
)

instruction = DRAFTER_INSTRUCTION
regeneration_notes = str(payload.get("regeneration_notes") or "").strip()
if regeneration_notes:
instruction = (
f"{instruction}\n\nThe user asked to regenerate this draft with this guidance: "
f"{regeneration_notes}"
)

last_error: SkillError | None = None
for attempt in range(2):
try:
result = client.invoke_json(skill, payload, DRAFTER_INSTRUCTION, DraftOutput)
result = client.invoke_json(skill, payload, instruction, DraftOutput)
assert isinstance(result, DraftOutput)
normalized = _normalize_draft(result, payload)
labeled = attach_evidence_labels(normalized, payload)
Expand Down
Loading