feat(l1): l1_session_service record_step + update_notes
record_step appends to walked_path JSONB and advances current_node_id on flow/proposal walks; refuses adhoc sessions. update_notes replaces walk_notes (used by adhoc walks for debounced autosave); 256KB size cap to prevent unbounded JSONB growth. Both reject non-active sessions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
start_* functions live in T12; step/notes are T13; resolve/escalate are T14.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
@@ -91,3 +93,59 @@ async def start_adhoc_session(
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def record_step(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
node_id: str,
|
||||
question: str,
|
||||
answer: str,
|
||||
note: Optional[str] = None,
|
||||
) -> L1WalkSession:
|
||||
"""Record an answered step in a tree walk. Appends to walked_path JSONB and
|
||||
advances current_node_id. Raises ValueError on adhoc sessions or inactive
|
||||
sessions. Updates last_step_at."""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.session_kind == "adhoc":
|
||||
raise ValueError("Cannot record step on adhoc session — use update_notes")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session {session_id} is not active (status={session.status})")
|
||||
entry = {
|
||||
"node_id": node_id,
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"l1_note": note,
|
||||
}
|
||||
# JSONB requires assigning a new list — in-place mutation isn't tracked
|
||||
session.walked_path = [*session.walked_path, entry]
|
||||
session.current_node_id = node_id
|
||||
session.last_step_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
|
||||
async def update_notes(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
session_id: UUID,
|
||||
notes: list[dict],
|
||||
) -> L1WalkSession:
|
||||
"""Replace walk_notes on an active session. Used by adhoc walks for
|
||||
debounced autosave. Raises ValueError if missing or inactive. Caps notes
|
||||
payload at 256KB to prevent unbounded growth."""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if not session:
|
||||
raise ValueError(f"L1WalkSession {session_id} not found")
|
||||
if session.status != "active":
|
||||
raise ValueError(f"Session {session_id} is not active (status={session.status})")
|
||||
encoded_size = len(json.dumps(notes).encode("utf-8"))
|
||||
if encoded_size > 256 * 1024:
|
||||
raise ValueError("walk_notes exceeds 256KB cap — consider escalating")
|
||||
session.walk_notes = notes
|
||||
session.last_step_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return session
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for l1_session_service start_* functions (T12)."""
|
||||
"""Tests for l1_session_service start_* functions (T12) and record_step/update_notes (T13)."""
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,6 +13,8 @@ from app.services.l1_session_service import (
|
||||
start_proposal_session,
|
||||
start_adhoc_session,
|
||||
_resolve_acting_as,
|
||||
record_step,
|
||||
update_notes,
|
||||
)
|
||||
|
||||
|
||||
@@ -219,3 +221,201 @@ async def test_engineer_with_coverage_gets_acting_as_tag(test_db: AsyncSession):
|
||||
ticket_kind="internal",
|
||||
)
|
||||
assert session.acting_as == "l1_coverage"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T13: record_step and update_notes tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_appends_to_walked_path(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id)
|
||||
session = await start_flow_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
flow_id=tree.id,
|
||||
ticket_id="t1",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
updated = await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n1",
|
||||
question="Is the device powered on?",
|
||||
answer="yes",
|
||||
)
|
||||
assert len(updated.walked_path) == 1
|
||||
assert updated.walked_path[0] == {
|
||||
"node_id": "n1",
|
||||
"question": "Is the device powered on?",
|
||||
"answer": "yes",
|
||||
"l1_note": None,
|
||||
}
|
||||
assert updated.current_node_id == "n1"
|
||||
assert updated.last_step_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_two_sequential_steps_accumulate(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id)
|
||||
session = await start_flow_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
flow_id=tree.id,
|
||||
ticket_id="t2",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n1",
|
||||
question="Step 1?",
|
||||
answer="yes",
|
||||
)
|
||||
updated = await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n2",
|
||||
question="Step 2?",
|
||||
answer="no",
|
||||
)
|
||||
assert len(updated.walked_path) == 2
|
||||
assert updated.walked_path[0]["node_id"] == "n1"
|
||||
assert updated.walked_path[1]["node_id"] == "n2"
|
||||
assert updated.current_node_id == "n2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_blocks_adhoc(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
session = await start_adhoc_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
ticket_id="t3",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
with pytest.raises(ValueError, match="adhoc"):
|
||||
await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n1",
|
||||
question="Q?",
|
||||
answer="yes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_blocks_inactive_session(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id)
|
||||
session = await start_flow_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
flow_id=tree.id,
|
||||
ticket_id="t4",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
# Manually mark resolved to simulate inactive state
|
||||
session.status = "resolved"
|
||||
await test_db.flush()
|
||||
with pytest.raises(ValueError, match="not active"):
|
||||
await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n1",
|
||||
question="Q?",
|
||||
answer="yes",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_step_includes_note_when_provided(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
tree = await _make_tree(test_db, account_id=account.id, author_id=l1.id)
|
||||
session = await start_flow_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
flow_id=tree.id,
|
||||
ticket_id="t5",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
updated = await record_step(
|
||||
test_db,
|
||||
session_id=session.id,
|
||||
node_id="n1",
|
||||
question="Q?",
|
||||
answer="yes",
|
||||
note="Customer mentioned it started yesterday",
|
||||
)
|
||||
assert updated.walked_path[0]["l1_note"] == "Customer mentioned it started yesterday"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notes_replaces_walk_notes(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
session = await start_adhoc_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
ticket_id="t6",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
new_notes = [{"timestamp": "2026-05-28T10:00:00Z", "content": "Customer rebooted"}]
|
||||
updated = await update_notes(test_db, session_id=session.id, notes=new_notes)
|
||||
assert updated.walk_notes == new_notes
|
||||
assert updated.last_step_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notes_raises_if_session_not_found(test_db: AsyncSession):
|
||||
missing_id = uuid.uuid4()
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
await update_notes(test_db, session_id=missing_id, notes=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notes_raises_if_session_not_active(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
session = await start_adhoc_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
ticket_id="t7",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
session.status = "escalated"
|
||||
await test_db.flush()
|
||||
with pytest.raises(ValueError, match="not active"):
|
||||
await update_notes(test_db, session_id=session.id, notes=[])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notes_size_cap(test_db: AsyncSession):
|
||||
account = await _make_account(test_db)
|
||||
l1 = await _make_user(test_db, account_id=account.id)
|
||||
session = await start_adhoc_session(
|
||||
test_db,
|
||||
account_id=account.id,
|
||||
user=l1,
|
||||
ticket_id="t8",
|
||||
ticket_kind="internal",
|
||||
)
|
||||
# Create a notes payload larger than 256KB
|
||||
big_content = "x" * (256 * 1024 + 100)
|
||||
notes = [{"timestamp": "2026-05-28T10:00:00Z", "content": big_content}]
|
||||
with pytest.raises(ValueError, match="256KB"):
|
||||
await update_notes(test_db, session_id=session.id, notes=notes)
|
||||
|
||||
Reference in New Issue
Block a user