"""Session object ledger for PMFlow AI turn runtime. The ledger is a customer-safe working memory of objects the assistant actually showed, focused, or acted on. It is not a business write surface and it does not infer intent from prose. """ from __future__ import annotations from typing import Any LEDGER_PACKET_VERSION = "pmflow_ai_object_ledger.v1" def build_object_ledger_from_payload( *, payload: dict[str, Any], previous_ledger: dict[str, Any] | None = None, ) -> dict[str, Any]: previous = previous_ledger if isinstance(previous_ledger, dict) else {} shown = _extract_objects_from_payload(payload) previous_recent = [ item for item in list(previous.get("recent_objects") or []) if isinstance(item, dict) ] recent = _dedupe_objects([*shown, *previous_recent])[:12] current_focus = _focus_from_payload(payload, shown=shown) if current_focus is None and len(shown) == 1: current_focus = shown[0] if current_focus is None: prior_focus = previous.get("current_focus_object") if isinstance(prior_focus, dict) and _object_ref(prior_focus): current_focus = _normalize_visible_object(prior_focus) active_topic = _active_topic_from_payload(payload=payload, current_focus=current_focus, previous=previous) return { "packet_version": LEDGER_PACKET_VERSION, "visibility": "customer", "current_focus_object": current_focus, "last_shown_objects": shown[:12], "recent_objects": recent, "active_topic": active_topic, } def object_ledger_from_context_capsules(value: Any) -> dict[str, Any]: rows = value if isinstance(value, list) else [] for item in rows: if not isinstance(item, dict): continue if str(item.get("packet_version") or "") == LEDGER_PACKET_VERSION: return dict(item) if str(item.get("capsule_type") or "") == "pmflow_ai_object_ledger": return dict(item) return {} def merge_object_ledger_into_context_packet( *, context_packet: dict[str, Any] | None, object_ledger: dict[str, Any], ) -> dict[str, Any]: packet = dict(context_packet or {}) if not object_ledger: return packet current = object_ledger.get("current_focus_object") shown = [item for item in list(object_ledger.get("last_shown_objects") or []) if isinstance(item, dict)] recent = [item for item in list(object_ledger.get("recent_objects") or []) if isinstance(item, dict)] if isinstance(current, dict) and _object_ref(current): packet["current_focus_object"] = _normalize_visible_object(current) if shown: packet["last_shown_objects"] = [_normalize_visible_object(item) for item in shown if _object_ref(item)] packet["visible_objects"] = _dedupe_objects([ *list(packet.get("visible_objects") or []), *packet["last_shown_objects"], ]) elif recent and not packet.get("last_shown_objects"): packet["last_shown_objects"] = [_normalize_visible_object(item) for item in recent[:6] if _object_ref(item)] packet.setdefault("packet_version", "customer_interaction_context.v1") packet.setdefault("visibility", "customer") return packet def upsert_object_ledger_capsule( *, capsules: Any, object_ledger: dict[str, Any], ) -> list[dict[str, Any]]: existing = [dict(item) for item in list(capsules or []) if isinstance(item, dict)] out = [ item for item in existing if str(item.get("packet_version") or "") != LEDGER_PACKET_VERSION and str(item.get("capsule_type") or "") != "pmflow_ai_object_ledger" ] if object_ledger: row = dict(object_ledger) row["capsule_type"] = "pmflow_ai_object_ledger" out.insert(0, row) return out[:12] def ledger_focus_as_turn_object(object_ledger: dict[str, Any] | None) -> dict[str, Any]: ledger = object_ledger if isinstance(object_ledger, dict) else {} current = ledger.get("current_focus_object") if not isinstance(current, dict) or not _object_ref(current): return {} obj = _normalize_visible_object(current) return { "object_type": obj.get("object_type") or "unknown", "object_ref": obj.get("object_ref") or "", "title": obj.get("title") or "", "binding_strength": "session_object_ledger", "role": "current_focus", } def _extract_objects_from_payload(payload: dict[str, Any]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] turn_state = payload.get("pmflow_ai_turn_state") if isinstance(payload.get("pmflow_ai_turn_state"), dict) else {} draft = turn_state.get("customer_packet_draft") if isinstance(turn_state.get("customer_packet_draft"), dict) else {} for item in list(draft.get("items") or []): if isinstance(item, dict): out.append(_normalize_visible_object(item)) for observation in list(turn_state.get("observations") or []): if not isinstance(observation, dict): continue for item in list(observation.get("items") or []): if isinstance(item, dict): out.append(_normalize_visible_object(item)) answer_canvas = payload.get("answer_canvas") if isinstance(payload.get("answer_canvas"), dict) else {} for item in list(answer_canvas.get("object_refs") or []): if isinstance(item, dict): out.append(_normalize_visible_object(item)) context = ( payload.get("customer_interaction_context_packet") if isinstance(payload.get("customer_interaction_context_packet"), dict) else {} ) for key in ("visible_objects", "last_shown_objects"): for item in list(context.get(key) or []): if isinstance(item, dict): out.append(_normalize_visible_object(item)) return _dedupe_objects([item for item in out if _object_ref(item) and str(item.get("title") or "").strip()]) def _focus_from_payload(payload: dict[str, Any], *, shown: list[dict[str, Any]]) -> dict[str, Any] | None: context = ( payload.get("customer_interaction_context_packet") if isinstance(payload.get("customer_interaction_context_packet"), dict) else {} ) focus = context.get("current_focus_object") if isinstance(focus, dict) and _object_ref(focus): return _normalize_visible_object(focus) turn_state = payload.get("pmflow_ai_turn_state") if isinstance(payload.get("pmflow_ai_turn_state"), dict) else {} object_state = turn_state.get("object_state") if isinstance(turn_state.get("object_state"), dict) else {} primary = object_state.get("primary_object") if isinstance(object_state.get("primary_object"), dict) else {} binding = str(primary.get("binding_strength") or "").strip().lower() if _object_ref(primary) and binding not in {"", "none", "context", "visible_context"}: return _normalize_visible_object(primary) if len(shown) == 1: return shown[0] return None def _active_topic_from_payload( *, payload: dict[str, Any], current_focus: dict[str, Any] | None, previous: dict[str, Any], ) -> dict[str, Any]: prior = previous.get("active_topic") if isinstance(previous.get("active_topic"), dict) else {} if current_focus and _object_ref(current_focus): answer_type = "" packet = payload.get("customer_evidence_packet") if isinstance(payload.get("customer_evidence_packet"), dict) else {} answer_type = str(packet.get("answer_type") or payload.get("answer_mode") or "").strip() return { "topic_type": answer_type[:80] or str(prior.get("topic_type") or "object_followup"), "primary_object_ref": current_focus.get("object_ref"), "primary_object_title": current_focus.get("title"), } return dict(prior) def _normalize_visible_object(value: dict[str, Any]) -> dict[str, Any]: object_type = _text(value.get("object_type") or value.get("type"), limit=64) or "object" object_ref = _object_ref(value) object_id = _object_id(value, object_ref=object_ref) title = _text(value.get("title") or value.get("project_title") or value.get("name"), limit=160) status = _text(value.get("status"), limit=80) href = _text(value.get("href"), limit=240) summary = _text(value.get("display_summary") or value.get("summary"), limit=800) out: dict[str, Any] = { "object_type": object_type, "object_ref": object_ref, "title": title, } if object_id is not None: out["object_id"] = object_id if status: out["status"] = status if href: out["href"] = href if summary: out["display_summary"] = summary return out def _dedupe_objects(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] seen: set[str] = set() for raw in rows: if not isinstance(raw, dict): continue item = _normalize_visible_object(raw) ref = _object_ref(item) if not ref or ref in seen or not str(item.get("title") or "").strip(): continue seen.add(ref) out.append(item) return out def _object_ref(value: dict[str, Any]) -> str: ref = _text(value.get("object_ref") or value.get("objectRef"), limit=120) if ref: return ref object_type = _text(value.get("object_type") or value.get("type"), limit=64) object_id = value.get("object_id") or value.get("objectId") or value.get("id") or value.get("project_id") if object_type and object_id is not None: return f"{object_type}:{object_id}" return "" def _object_id(value: dict[str, Any], *, object_ref: str) -> int | None: for key in ("object_id", "objectId", "id", "project_id"): try: if value.get(key) is not None: return int(value.get(key)) except Exception: continue if ":" in object_ref: tail = object_ref.rsplit(":", 1)[-1] try: return int(tail) except Exception: return None return None def _text(value: Any, *, limit: int) -> str: return " ".join(str(value or "").strip().split())[:limit] __all__ = [ "LEDGER_PACKET_VERSION", "build_object_ledger_from_payload", "ledger_focus_as_turn_object", "merge_object_ledger_into_context_packet", "object_ledger_from_context_capsules", "upsert_object_ledger_capsule", ]