PMFlow/backend/app/services/pmflow_ai_turn_object_ledge...

263 lines
10 KiB
Python

"""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",
]