Adds the AI-proposed resolution path and the inline preview of the
markdown that will be posted to the customer ticket on Resolve. The
preview is keyed on (session_id, ai_sessions.state_version) so back-to-
back fetches against unchanged state hit an in-process cache instead
of paying for a Sonnet call.
Backend:
- preview_cache: in-process LRU keyed on (kind, session_id, state_version).
No TTL — state_version is the source of truth. Soft-cap 5000 entries.
- unified_chat_service: [SUGGEST_FIX] parser (last-block-wins, JSON
payload, confidence clamped 0-100), supersession persistence (sets
superseded_at on prior active row), atomic state_version bump.
- ResolutionNoteGeneratorService: pulls session, facts, active fix, and
redacted script_generations into a structured input bundle for Sonnet;
produces the four-section markdown (Problem / What we confirmed /
Root cause / Resolution). Sensitive script parameters redacted via
ScriptTemplateEngine.redact_sensitive driven by the template's
parameters_schema.
- /api/v1/ai-sessions/{id}/suggested-fixes/active — 200 with the active
fix or 404.
- /api/v1/ai-sessions/{id}/suggested-fixes/{fix_id}/decision — records
one_off / draft_template / build_template / dismissed; dismiss
supersedes; bumps state_version. 409 on dismissing an already-
superseded fix.
- /api/v1/ai-sessions/{id}/resolution-note/preview — generates or returns
cached markdown; from_cache flag in payload signals cache hit.
- scripts.py POST /generate now bumps state_version on the linked
ai_session_id when present (third source of preview-cache invalidation
per Section 5.5).
- ASSISTANT_SYSTEM_PROMPT documents [SUGGEST_FIX] (when to/not to emit,
format, supersession semantics).
- 12 tests covering the parser (well-formed, last-wins, malformed,
confidence clamping), supersession + state_version invariant, all
decision branches, preview cache hit-on-no-change + miss-after-write.
Frontend:
- src/components/pilot/sections/SuggestedFix.tsx — amber-accented card
with confidence badge; dismiss action wired to the decision endpoint.
- src/components/pilot/ResolutionNotePreview.tsx — popover with refresh,
loading state, cached/fresh indicator, ticket-ref display.
- src/api/sessionSuggestedFixes.ts — typed client; getActive normalizes
404 to null so callers don't have to special-case.
- TaskLane gains suggestedFixSlot + bottomSlot props (rendered after
Diagnostic Checks; bottomSlot anchors the Resolve action).
- AssistantChatPage: refreshSessionDerived helper batches fact + fix
refresh; fact mutations and chat sends both schedule a 500ms-debounced
preview refresh per the Section 5.5 spec.
Verified end-to-end against the dev stack with a real Sonnet call:
- /active 404 → fact create → preview generates four-section markdown
grounded only in provided facts → second preview call hits cache
(from_cache=true, no LLM call) → fact write 2 → cache miss, regenerates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
53 lines
2.0 KiB
Python
53 lines
2.0 KiB
Python
"""In-process preview cache for FlowPilot resolution-note / escalation-package previews.
|
|
|
|
Phase 3 implementation per FLOWPILOT-MIGRATION.md Section 5.5:
|
|
- Cache key: `(kind, session_id, state_version)` — no TTL needed, state_version
|
|
is the source of truth.
|
|
- Invalidation: any write to session_facts, session_suggested_fixes, or
|
|
script_generations bumps `ai_sessions.state_version`. Old entries simply
|
|
stop being looked up and leak harmlessly until process restart.
|
|
- Storage: plain dict, single-process. When Session Sharing brings Redis,
|
|
swap the storage without changing the call sites.
|
|
|
|
Bound: best-effort soft cap of 5000 entries. When exceeded we drop the
|
|
oldest insertion. Not a TTL — at current scale, the cap is more about
|
|
resident-memory hygiene than correctness.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from collections import OrderedDict
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
_MAX_ENTRIES = 5000
|
|
|
|
|
|
class _PreviewCache:
|
|
def __init__(self) -> None:
|
|
self._store: OrderedDict[tuple[str, UUID, int], Any] = OrderedDict()
|
|
|
|
def get(self, kind: str, session_id: UUID, state_version: int) -> Any | None:
|
|
key = (kind, session_id, state_version)
|
|
if key not in self._store:
|
|
return None
|
|
# Touch on access so LRU eviction is meaningful.
|
|
self._store.move_to_end(key)
|
|
return self._store[key]
|
|
|
|
def set(self, kind: str, session_id: UUID, state_version: int, value: Any) -> None:
|
|
key = (kind, session_id, state_version)
|
|
self._store[key] = value
|
|
self._store.move_to_end(key)
|
|
# Evict oldest if over cap. OrderedDict.popitem(last=False) is O(1).
|
|
while len(self._store) > _MAX_ENTRIES:
|
|
self._store.popitem(last=False)
|
|
|
|
def invalidate_session(self, session_id: UUID) -> None:
|
|
"""Drop all entries for a session — used when the session is deleted."""
|
|
keys = [k for k in self._store if k[1] == session_id]
|
|
for k in keys:
|
|
del self._store[k]
|
|
|
|
|
|
preview_cache = _PreviewCache()
|