1643 lines
70 KiB
Python
1643 lines
70 KiB
Python
"""Customer-safe Agent Runtime contracts for Space PMFlow AI."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Literal
|
||
from urllib.parse import unquote, urlparse
|
||
from uuid import uuid4
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||
|
||
|
||
AGENT_RUN_PACKET_VERSION = "pmflow_ai_agent_run.v1"
|
||
AGENT_WORKSPACE_VERSION = "pmflow_ai_agent_workspace.v1"
|
||
AGENT_STEP_VERSION = "pmflow_ai_agent_step.v1"
|
||
AGENT_OBSERVATION_VERSION = "pmflow_ai_agent_observation.v1"
|
||
AGENT_TOOL_CONTRACT_VERSION = "pmflow_ai_agent_tool_contract.v1"
|
||
AGENT_RUN_EVENT_VERSION = "pmflow_ai_agent_run_event.v1"
|
||
AGENT_RUN_BUDGET_VERSION = "pmflow_ai_agent_run_budget.v1"
|
||
AGENT_GOAL_FRAME_VERSION = "pmflow_ai_agent_goal_frame.v1"
|
||
AGENT_EVIDENCE_NORMALIZATION_VERSION = "pmflow_ai_agent_evidence_normalization.v1"
|
||
AGENT_TERMINAL_STATE_VERSION = "pmflow_ai_agent_terminal_answer_state.v1"
|
||
AGENT_ANSWER_RUNTIME_PACKET_VERSION = "pmflow_ai_agent_answer_runtime_packet.v1"
|
||
AGENT_DELIVERABLE_PACKET_VERSION = "pmflow_ai_agent_deliverable_packet.v1"
|
||
|
||
|
||
def new_agent_id(prefix: str) -> str:
|
||
return f"{prefix}_{uuid4().hex[:12]}"
|
||
|
||
|
||
def utc_now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def clean_text(value: Any, *, limit: int = 800) -> str:
|
||
return " ".join(str(value or "").strip().split())[:limit]
|
||
|
||
|
||
def clean_markdown_text(value: Any, *, limit: int = 6000) -> str:
|
||
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||
if not text:
|
||
return ""
|
||
lines = [re.sub(r"[ \t]+", " ", line).rstrip() for line in text.split("\n")]
|
||
normalized = "\n".join(lines).strip()
|
||
normalized = re.sub(r"\n{3,}", "\n\n", normalized)
|
||
return normalized[:limit]
|
||
|
||
|
||
class PMFlowAIAgentObservation(BaseModel):
|
||
"""One observation produced by the agent environment or a future tool."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_OBSERVATION_VERSION
|
||
observation_id: str = Field(default_factory=lambda: new_agent_id("obs"), max_length=80)
|
||
source: str = Field(default="agent_runtime", max_length=80)
|
||
observation_type: str = Field(default="workspace_snapshot", max_length=80)
|
||
summary: str = Field(default="", max_length=1200)
|
||
facts: list[dict[str, Any]] = Field(default_factory=list)
|
||
sources: list[dict[str, Any]] = Field(default_factory=list)
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
relevance_to_goal: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
safe_for_customer: bool = True
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("observation_id", "source", "observation_type", "summary", mode="before")
|
||
@classmethod
|
||
def _normalize_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1200)
|
||
|
||
|
||
class PMFlowAIAgentStep(BaseModel):
|
||
"""A visible agent step. These steps are the only source for process UI."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_STEP_VERSION
|
||
step_id: str = Field(default_factory=lambda: new_agent_id("step"), max_length=80)
|
||
key: str = Field(..., min_length=1, max_length=80)
|
||
label: str = Field(..., min_length=1, max_length=160)
|
||
step_type: Literal[
|
||
"understand_goal",
|
||
"bind_context",
|
||
"observe_state",
|
||
"read_evidence",
|
||
"extract_claims",
|
||
"assess_sufficiency",
|
||
"recover_or_continue",
|
||
"prepare_action",
|
||
"wait_confirmation",
|
||
"workspace_snapshot",
|
||
"planner",
|
||
"tool",
|
||
"critic",
|
||
"answer",
|
||
] = "understand_goal"
|
||
status: Literal["pending", "active", "completed", "skipped"] = "completed"
|
||
selected_tool: str | None = Field(default=None, max_length=80)
|
||
observation_id: str | None = Field(default=None, max_length=80)
|
||
display_summary: str | None = Field(default=None, max_length=360)
|
||
duration_ms: int | None = Field(default=None, ge=0)
|
||
blocking: bool = False
|
||
|
||
@field_validator("step_id", "key", "label", "selected_tool", "observation_id", "display_summary", mode="before")
|
||
@classmethod
|
||
def _normalize_step_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=360)
|
||
|
||
|
||
class PMFlowAIAgentStepProposal(BaseModel):
|
||
"""Planner output for one next agent step.
|
||
|
||
This is intentionally a runtime action contract, not a business scenario
|
||
classifier. Tool names are validated by the runtime tool registry.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
action: Literal["tool", "answer", "ask_user", "stop_blocked"] = "tool"
|
||
tool_name: str | None = Field(default=None, max_length=80)
|
||
tool_input: dict[str, Any] = Field(default_factory=dict)
|
||
rationale: str = Field(default="", max_length=1000)
|
||
expected_observation: str = Field(default="", max_length=1000)
|
||
stop_condition: str = Field(default="", max_length=1000)
|
||
risks_if_answer_now: list[str] = Field(default_factory=list)
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
|
||
@field_validator("tool_name", "rationale", "expected_observation", "stop_condition", mode="before")
|
||
@classmethod
|
||
def _normalize_proposal_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1000)
|
||
|
||
@field_validator("risks_if_answer_now", mode="before")
|
||
@classmethod
|
||
def _normalize_risk_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[str] = []
|
||
for item in rows:
|
||
text = clean_text(item, limit=360)
|
||
if text and text not in out:
|
||
out.append(text)
|
||
return out[:8]
|
||
|
||
|
||
class PMFlowAIAgentToolContract(BaseModel):
|
||
"""Typed capability contract for an Agent Runtime tool."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_TOOL_CONTRACT_VERSION
|
||
tool_name: str = Field(..., min_length=1, max_length=80)
|
||
aliases: list[str] = Field(default_factory=list)
|
||
capability: str = Field(default="", max_length=240)
|
||
input_schema: dict[str, Any] = Field(default_factory=dict)
|
||
output_schema: dict[str, Any] = Field(default_factory=dict)
|
||
risk_level: Literal["low", "medium", "high"] = "low"
|
||
read_or_write: Literal["read", "write"] = "read"
|
||
preconditions: list[str] = Field(default_factory=list)
|
||
permission_boundary: str = Field(default="", max_length=500)
|
||
success_signal: str = Field(default="", max_length=300)
|
||
failure_signal: str = Field(default="", max_length=300)
|
||
side_effects: list[str] = Field(default_factory=list)
|
||
customer_safe_projection: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
@field_validator("tool_name", "capability", "permission_boundary", "success_signal", "failure_signal", mode="before")
|
||
@classmethod
|
||
def _normalize_contract_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=500)
|
||
|
||
@field_validator("aliases", "preconditions", "side_effects", mode="before")
|
||
@classmethod
|
||
def _normalize_contract_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[str] = []
|
||
for item in rows:
|
||
text = clean_text(item, limit=240)
|
||
if text and text not in out:
|
||
out.append(text)
|
||
return out[:16]
|
||
|
||
|
||
class PMFlowAIAgentRunBudget(BaseModel):
|
||
"""Runtime budget for one bounded agent loop."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_RUN_BUDGET_VERSION
|
||
max_iterations: int = Field(default=4, ge=1, le=8)
|
||
max_tool_calls: int = Field(default=6, ge=0, le=8)
|
||
max_repeated_tool_signatures: int = Field(default=0, ge=0, le=3)
|
||
|
||
|
||
class PMFlowAIAgentRunEvent(BaseModel):
|
||
"""Customer-safe execution event emitted by the agent loop."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_RUN_EVENT_VERSION
|
||
event_id: str = Field(default_factory=lambda: new_agent_id("evt"), max_length=80)
|
||
event_type: Literal[
|
||
"goal_frame",
|
||
"workspace_snapshot",
|
||
"plan",
|
||
"tool_observation",
|
||
"evidence_normalized",
|
||
"critic_review",
|
||
"runtime_stop",
|
||
"answer",
|
||
] = "workspace_snapshot"
|
||
iteration: int = Field(default=0, ge=0)
|
||
action: str | None = Field(default=None, max_length=80)
|
||
tool_name: str | None = Field(default=None, max_length=80)
|
||
observation_id: str | None = Field(default=None, max_length=80)
|
||
state_changed: bool = False
|
||
duration_ms: int | None = Field(default=None, ge=0)
|
||
summary: str = Field(default="", max_length=800)
|
||
stop_reason: str | None = Field(default=None, max_length=120)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("event_id", "action", "tool_name", "observation_id", "summary", "stop_reason", mode="before")
|
||
@classmethod
|
||
def _normalize_event_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=800)
|
||
|
||
|
||
class PMFlowAIAgentCriticResult(BaseModel):
|
||
"""State critic result after observing a tool result."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
ready_to_answer: bool = False
|
||
should_continue: bool = False
|
||
needs_user_input: bool = False
|
||
retry_would_change_state: bool = True
|
||
state_delta: dict[str, Any] = Field(default_factory=dict)
|
||
findings: list[str] = Field(default_factory=list)
|
||
missing_state: list[str] = Field(default_factory=list)
|
||
recover_or_continue: Literal["continue_with_tool", "narrow_scope", "ask_user", "stop_blocked", "compose_answer"] = "continue_with_tool"
|
||
next_step_hint: str = Field(default="", max_length=1000)
|
||
reason: str = Field(default="", max_length=1000)
|
||
|
||
@field_validator("findings", "missing_state", mode="before")
|
||
@classmethod
|
||
def _normalize_text_rows(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[str] = []
|
||
for item in rows:
|
||
text = clean_text(item, limit=360)
|
||
if text and text not in out:
|
||
out.append(text)
|
||
return out[:12]
|
||
|
||
@field_validator("next_step_hint", "reason", mode="before")
|
||
@classmethod
|
||
def _normalize_critic_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1000)
|
||
|
||
|
||
class PMFlowAIAgentGoalFrame(BaseModel):
|
||
"""LLM-authored goal contract for one agent turn.
|
||
|
||
This is not a business scenario enum. It captures the user's goal,
|
||
objects/evidence to bind, success criteria and hard boundaries so the
|
||
runtime can validate and constrain subsequent generic agent primitives.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_GOAL_FRAME_VERSION
|
||
frame_id: str = Field(default_factory=lambda: new_agent_id("goal"), max_length=80)
|
||
user_goal: str = Field(default="", max_length=1200)
|
||
desired_output: str = Field(default="", max_length=1200)
|
||
interpretation_candidates: list[dict[str, Any]] = Field(default_factory=list)
|
||
interpretation_branches: list[dict[str, Any]] = Field(default_factory=list)
|
||
selected_interpretation: str = Field(default="", max_length=600)
|
||
answer_policy: Literal["directional_answer", "partial_answer", "strict_verified", "official_only", "clarify_first"] = "directional_answer"
|
||
source_policy: Literal["credible_public_sources", "official_preferred", "official_only", "internal_customer_safe"] = "credible_public_sources"
|
||
freshness_policy: Literal["prefer_recent_not_strict", "strict_time_window", "not_required"] = "prefer_recent_not_strict"
|
||
missing_evidence_policy: Literal["answer_with_boundaries", "fail_closed", "ask_user"] = "answer_with_boundaries"
|
||
objects_to_bind: list[str] = Field(default_factory=list)
|
||
evidence_requirements: list[str] = Field(default_factory=list)
|
||
success_criteria: list[str] = Field(default_factory=list)
|
||
answer_objective: dict[str, Any] = Field(default_factory=dict)
|
||
dynamic_rubric: list[dict[str, Any]] = Field(default_factory=list)
|
||
evidence_lanes: list[dict[str, Any]] = Field(default_factory=list)
|
||
disclosure_policy: dict[str, Any] = Field(default_factory=dict)
|
||
latency_policy: dict[str, Any] = Field(default_factory=dict)
|
||
deliverable_shape: dict[str, Any] = Field(default_factory=dict)
|
||
boundaries: list[str] = Field(default_factory=list)
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
|
||
@field_validator("frame_id", "user_goal", "desired_output", "selected_interpretation", mode="before")
|
||
@classmethod
|
||
def _normalize_goal_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1200)
|
||
|
||
@field_validator("answer_policy", mode="before")
|
||
@classmethod
|
||
def _normalize_answer_policy(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=80)
|
||
return text if text in {"directional_answer", "partial_answer", "strict_verified", "official_only", "clarify_first"} else "directional_answer"
|
||
|
||
@field_validator("source_policy", mode="before")
|
||
@classmethod
|
||
def _normalize_source_policy(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=80)
|
||
return text if text in {"credible_public_sources", "official_preferred", "official_only", "internal_customer_safe"} else "credible_public_sources"
|
||
|
||
@field_validator("freshness_policy", mode="before")
|
||
@classmethod
|
||
def _normalize_freshness_policy(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=80)
|
||
return text if text in {"prefer_recent_not_strict", "strict_time_window", "not_required"} else "prefer_recent_not_strict"
|
||
|
||
@field_validator("missing_evidence_policy", mode="before")
|
||
@classmethod
|
||
def _normalize_missing_evidence_policy(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=80)
|
||
return text if text in {"answer_with_boundaries", "fail_closed", "ask_user"} else "answer_with_boundaries"
|
||
|
||
@field_validator("disclosure_policy", mode="before")
|
||
@classmethod
|
||
def _normalize_disclosure_policy(cls, value: Any) -> dict[str, Any]:
|
||
raw = value if isinstance(value, dict) else {}
|
||
visibility = clean_text(raw.get("visibility_class"), limit=80)
|
||
if visibility not in {
|
||
"public_product_capability",
|
||
"tenant_business_state",
|
||
"external_public",
|
||
"protected_pmflow_internal",
|
||
"platform_admin_debug",
|
||
}:
|
||
visibility = ""
|
||
response_mode = clean_text(raw.get("allowed_response_mode"), limit=80)
|
||
if response_mode not in {
|
||
"answer",
|
||
"safe_product_boundary",
|
||
"protected_refusal",
|
||
"platform_admin_redirect",
|
||
}:
|
||
response_mode = ""
|
||
categories = [
|
||
clean_text(item, limit=80)
|
||
for item in list(raw.get("protected_categories") or [])[:12]
|
||
if clean_text(item, limit=80)
|
||
]
|
||
contract_key = clean_text(raw.get("contract_key"), limit=120)
|
||
if visibility == "protected_pmflow_internal" or response_mode == "protected_refusal":
|
||
visibility = "protected_pmflow_internal"
|
||
response_mode = "protected_refusal"
|
||
contract_key = contract_key or "protected_internal_refusal.v1"
|
||
if not categories:
|
||
categories = ["pmflow_internal_implementation"]
|
||
return {
|
||
"visibility_class": visibility,
|
||
"protected_categories": categories,
|
||
"allowed_response_mode": response_mode,
|
||
"policy_reason": clean_text(raw.get("policy_reason"), limit=400),
|
||
"contract_key": contract_key,
|
||
}
|
||
|
||
@field_validator("interpretation_candidates", mode="before")
|
||
@classmethod
|
||
def _normalize_interpretation_candidates(cls, value: Any) -> list[dict[str, Any]]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[dict[str, Any]] = []
|
||
for item in rows[:6]:
|
||
if isinstance(item, dict):
|
||
meaning = clean_text(item.get("meaning") or item.get("summary") or item.get("label"), limit=360)
|
||
confidence = clean_text(item.get("confidence") or "unknown", limit=40)
|
||
if meaning:
|
||
out.append(
|
||
{
|
||
"meaning": meaning,
|
||
"confidence": confidence if confidence in {"high", "medium", "low", "unknown"} else "unknown",
|
||
}
|
||
)
|
||
continue
|
||
meaning = clean_text(item, limit=360)
|
||
if meaning:
|
||
out.append({"meaning": meaning, "confidence": "unknown"})
|
||
return out
|
||
|
||
@field_validator("interpretation_branches", mode="before")
|
||
@classmethod
|
||
def _normalize_interpretation_branches(cls, value: Any) -> list[dict[str, Any]]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[dict[str, Any]] = []
|
||
for index, item in enumerate(rows[:6], start=1):
|
||
if not isinstance(item, dict):
|
||
meaning = clean_text(item, limit=500)
|
||
if meaning:
|
||
out.append(
|
||
{
|
||
"branch_id": f"b{index}",
|
||
"meaning": meaning,
|
||
"confidence": "unknown",
|
||
"user_language_prior": "unknown",
|
||
"anchor_terms": [],
|
||
"missing_anchors": [],
|
||
}
|
||
)
|
||
continue
|
||
meaning = clean_text(item.get("meaning") or item.get("summary") or item.get("label"), limit=500)
|
||
if not meaning:
|
||
continue
|
||
branch_id = clean_text(item.get("branch_id") or item.get("id") or f"b{index}", limit=80)
|
||
confidence = clean_text(item.get("confidence") or "unknown", limit=40)
|
||
prior = clean_text(item.get("user_language_prior") or item.get("language_prior") or confidence or "unknown", limit=40)
|
||
out.append(
|
||
{
|
||
"branch_id": branch_id or f"b{index}",
|
||
"meaning": meaning,
|
||
"semantic_decomposition": clean_text(item.get("semantic_decomposition") or "", limit=500),
|
||
"confidence": confidence if confidence in {"high", "medium", "low", "unknown"} else "unknown",
|
||
"user_language_prior": prior if prior in {"high", "medium", "low", "unknown"} else "unknown",
|
||
"generated_by": clean_text(item.get("generated_by") or "", limit=80),
|
||
"provisional_assumption": clean_text(
|
||
item.get("provisional_assumption") or item.get("assumption") or "",
|
||
limit=360,
|
||
),
|
||
"answer_assumption_instruction": clean_text(
|
||
item.get("answer_assumption_instruction") or item.get("answer_instruction") or "",
|
||
limit=360,
|
||
),
|
||
"evidence_probe_queries": [
|
||
clean_text(query, limit=220)
|
||
for query in list(item.get("evidence_probe_queries") or item.get("probe_queries") or [])[:6]
|
||
if clean_text(query, limit=220)
|
||
],
|
||
"anchor_terms": [
|
||
clean_text(term, limit=80)
|
||
for term in list(item.get("anchor_terms") or [])[:8]
|
||
if clean_text(term, limit=80)
|
||
],
|
||
"missing_anchors": [
|
||
clean_text(term, limit=120)
|
||
for term in list(item.get("missing_anchors") or [])[:8]
|
||
if clean_text(term, limit=120)
|
||
],
|
||
}
|
||
)
|
||
return out
|
||
|
||
@model_validator(mode="after")
|
||
def _ensure_interpretation_branches(self) -> "PMFlowAIAgentGoalFrame":
|
||
if not self.interpretation_branches and self.interpretation_candidates:
|
||
self.interpretation_branches = [
|
||
{
|
||
"branch_id": f"b{index}",
|
||
"meaning": str(item.get("meaning") or "")[:500],
|
||
"confidence": str(item.get("confidence") or "unknown"),
|
||
"user_language_prior": str(item.get("confidence") or "unknown"),
|
||
"generated_by": "",
|
||
"provisional_assumption": "",
|
||
"answer_assumption_instruction": "",
|
||
"evidence_probe_queries": [],
|
||
"anchor_terms": [],
|
||
"missing_anchors": [],
|
||
}
|
||
for index, item in enumerate(self.interpretation_candidates[:6], start=1)
|
||
if str(item.get("meaning") or "").strip()
|
||
]
|
||
return self
|
||
|
||
@field_validator("objects_to_bind", "evidence_requirements", "success_criteria", "boundaries", mode="before")
|
||
@classmethod
|
||
def _normalize_goal_lists(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[str] = []
|
||
for item in rows:
|
||
text = clean_text(item, limit=500)
|
||
if text and text not in out:
|
||
out.append(text)
|
||
return out[:16]
|
||
|
||
@field_validator("dynamic_rubric", "evidence_lanes", mode="before")
|
||
@classmethod
|
||
def _normalize_goal_dict_rows(cls, value: Any) -> list[dict[str, Any]]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[dict[str, Any]] = []
|
||
for item in rows[:12]:
|
||
if not isinstance(item, dict):
|
||
text = clean_text(item, limit=360)
|
||
if text:
|
||
out.append({"criterion": text})
|
||
continue
|
||
normalized: dict[str, Any] = {}
|
||
for key, raw in item.items():
|
||
key_text = clean_text(key, limit=80)
|
||
if not key_text:
|
||
continue
|
||
if isinstance(raw, list):
|
||
normalized[key_text] = [clean_text(row, limit=240) for row in raw[:8] if clean_text(row, limit=240)]
|
||
elif isinstance(raw, dict):
|
||
normalized[key_text] = {
|
||
clean_text(k, limit=80): clean_text(v, limit=240)
|
||
for k, v in raw.items()
|
||
if clean_text(k, limit=80) and clean_text(v, limit=240)
|
||
}
|
||
else:
|
||
text = clean_text(raw, limit=360)
|
||
if text:
|
||
normalized[key_text] = text
|
||
if normalized:
|
||
out.append(normalized)
|
||
return out
|
||
|
||
@field_validator("answer_objective", "latency_policy", "deliverable_shape", mode="before")
|
||
@classmethod
|
||
def _normalize_goal_dict(cls, value: Any) -> dict[str, Any]:
|
||
if not isinstance(value, dict):
|
||
return {}
|
||
out: dict[str, Any] = {}
|
||
for key, raw in value.items():
|
||
key_text = clean_text(key, limit=80)
|
||
if not key_text:
|
||
continue
|
||
if isinstance(raw, list):
|
||
out[key_text] = [clean_text(item, limit=260) for item in raw[:16] if clean_text(item, limit=260)]
|
||
elif isinstance(raw, dict):
|
||
out[key_text] = {
|
||
clean_text(k, limit=80): clean_text(v, limit=260)
|
||
for k, v in raw.items()
|
||
if clean_text(k, limit=80) and clean_text(v, limit=260)
|
||
}
|
||
else:
|
||
text = clean_text(raw, limit=500)
|
||
if text:
|
||
out[key_text] = text
|
||
return out
|
||
|
||
|
||
class PMFlowAIAgentClaim(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
claim_id: str = Field(default_factory=lambda: new_agent_id("claim"), max_length=80)
|
||
claim_type: str = Field(default="observation_claim", max_length=80)
|
||
claim_text: str = Field(default="", max_length=1200)
|
||
customer_value: str = Field(default="", max_length=500)
|
||
support_refs: list[str] = Field(default_factory=list)
|
||
source_tool: str = Field(default="", max_length=120)
|
||
source_kind: str = Field(default="", max_length=120)
|
||
interpretation_branch_id: str | None = Field(default=None, max_length=80)
|
||
evidence_lane: str = Field(default="", max_length=160)
|
||
coverage_contribution: str = Field(default="", max_length=500)
|
||
time_match: Literal["exact", "recent", "stale", "unknown"] = "unknown"
|
||
target_match: Literal["direct", "partial", "off_target", "unknown"] = "unknown"
|
||
authority_level: Literal["primary", "official", "credible_secondary", "weak", "unknown"] = "unknown"
|
||
relevance_to_user_goal: Literal["direct", "partial", "off_branch", "unknown"] = "unknown"
|
||
claim_kind: Literal["substantive_fact", "directional_judgement", "source_candidate", "boundary", "unknown"] = "unknown"
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
allowed_to_state: bool = True
|
||
|
||
@field_validator(
|
||
"claim_id",
|
||
"claim_type",
|
||
"claim_text",
|
||
"customer_value",
|
||
"source_tool",
|
||
"source_kind",
|
||
"interpretation_branch_id",
|
||
"evidence_lane",
|
||
"coverage_contribution",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _normalize_claim_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1200)
|
||
|
||
@field_validator("time_match", mode="before")
|
||
@classmethod
|
||
def _normalize_time_match(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=40)
|
||
return text if text in {"exact", "recent", "stale", "unknown"} else "unknown"
|
||
|
||
@field_validator("target_match", mode="before")
|
||
@classmethod
|
||
def _normalize_target_match(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=40)
|
||
return text if text in {"direct", "partial", "off_target", "unknown"} else "unknown"
|
||
|
||
@field_validator("authority_level", mode="before")
|
||
@classmethod
|
||
def _normalize_authority_level(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=60)
|
||
return text if text in {"primary", "official", "credible_secondary", "weak", "unknown"} else "unknown"
|
||
|
||
@field_validator("relevance_to_user_goal", mode="before")
|
||
@classmethod
|
||
def _normalize_claim_relevance(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=40)
|
||
return text if text in {"direct", "partial", "off_branch", "unknown"} else "unknown"
|
||
|
||
@field_validator("claim_kind", mode="before")
|
||
@classmethod
|
||
def _normalize_claim_kind(cls, value: Any) -> str:
|
||
text = clean_text(value, limit=60)
|
||
return text if text in {"substantive_fact", "directional_judgement", "source_candidate", "boundary", "unknown"} else "unknown"
|
||
|
||
@field_validator("support_refs", mode="before")
|
||
@classmethod
|
||
def _normalize_support_refs(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentBoundary(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
boundary_id: str = Field(default_factory=lambda: new_agent_id("boundary"), max_length=80)
|
||
boundary_type: str = Field(default="evidence_boundary", max_length=80)
|
||
blocked_claim: str = Field(default="", max_length=1000)
|
||
customer_safe_reason: str = Field(default="", max_length=1000)
|
||
allowed_alternative: str = Field(default="", max_length=1000)
|
||
support_refs: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("boundary_id", "boundary_type", "blocked_claim", "customer_safe_reason", "allowed_alternative", mode="before")
|
||
@classmethod
|
||
def _normalize_boundary_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1000)
|
||
|
||
@field_validator("support_refs", mode="before")
|
||
@classmethod
|
||
def _normalize_support_refs(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentSourceNormalization(BaseModel):
|
||
"""Per-source normalization result.
|
||
|
||
This is a customer-safe projection, not raw source storage. Raw snippets stay
|
||
inside observations and must not be consumed by the answer runtime.
|
||
"""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
source_ref: str = Field(default="", max_length=300)
|
||
source_title: str = Field(default="", max_length=240)
|
||
source_quality: Literal["usable", "noisy", "blocked", "off_topic", "duplicate"] = "noisy"
|
||
usable_claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
|
||
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
|
||
gaps: list[str] = Field(default_factory=list)
|
||
rejected_reason: str = Field(default="", max_length=1000)
|
||
|
||
@field_validator("source_ref", "source_title", "rejected_reason", mode="before")
|
||
@classmethod
|
||
def _normalize_source_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1000)
|
||
|
||
@field_validator("gaps", mode="before")
|
||
@classmethod
|
||
def _normalize_source_gap_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
|
||
|
||
@model_validator(mode="after")
|
||
def _enforce_quality_contract(self) -> "PMFlowAIAgentSourceNormalization":
|
||
if self.source_quality != "usable":
|
||
self.usable_claims = []
|
||
return self
|
||
|
||
|
||
class PMFlowAIAgentEvidenceNormalization(BaseModel):
|
||
"""Normalized evidence projection from one raw observation."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_EVIDENCE_NORMALIZATION_VERSION
|
||
normalization_id: str = Field(default_factory=lambda: new_agent_id("evnorm"), max_length=80)
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
source_quality: Literal["usable", "noisy", "blocked", "off_topic", "duplicate"] = "noisy"
|
||
source_normalizations: list[PMFlowAIAgentSourceNormalization] = Field(default_factory=list)
|
||
usable_claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
|
||
affordances: list[PMFlowAIAgentAffordanceLedgerEntry] = Field(default_factory=list)
|
||
action_candidates: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
|
||
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
|
||
gaps: list[str] = Field(default_factory=list)
|
||
rejected_reason: str = Field(default="", max_length=1000)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("normalization_id", "source_observation_id", "rejected_reason", mode="before")
|
||
@classmethod
|
||
def _normalize_normalization_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1000)
|
||
|
||
@field_validator("gaps", mode="before")
|
||
@classmethod
|
||
def _normalize_gap_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
|
||
|
||
@model_validator(mode="after")
|
||
def _enforce_quality_contract(self) -> "PMFlowAIAgentEvidenceNormalization":
|
||
if self.source_quality != "usable":
|
||
self.usable_claims = []
|
||
return self
|
||
|
||
|
||
class PMFlowAIAgentAttempt(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
attempt_id: str = Field(default_factory=lambda: new_agent_id("attempt"), max_length=80)
|
||
action: str = Field(default="", max_length=120)
|
||
outcome: str = Field(default="", max_length=500)
|
||
observation_id: str | None = Field(default=None, max_length=80)
|
||
|
||
@field_validator("attempt_id", "action", "outcome", "observation_id", mode="before")
|
||
@classmethod
|
||
def _normalize_attempt_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=500)
|
||
|
||
|
||
class PMFlowAIAgentAnswerIntentPlan(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
intent_id: str = Field(default="agent_answer_intent", max_length=80)
|
||
lead_claim_id: str = Field(default="", max_length=80)
|
||
lead_boundary_id: str = Field(default="", max_length=80)
|
||
detail_level: Literal["brief", "standard", "deep"] = "standard"
|
||
structure_preference: str = Field(default="short_paragraphs_no_heading", max_length=120)
|
||
answer_moves: list[str] = Field(default_factory=list)
|
||
composition_guidance: list[str] = Field(default_factory=list)
|
||
anti_template_rules: list[str] = Field(default_factory=list)
|
||
must_include_claim_ids: list[str] = Field(default_factory=list)
|
||
must_include_boundary_ids: list[str] = Field(default_factory=list)
|
||
avoid_sections: bool = True
|
||
|
||
@field_validator("intent_id", "lead_claim_id", "lead_boundary_id", "structure_preference", mode="before")
|
||
@classmethod
|
||
def _normalize_plan_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=160)
|
||
|
||
@field_validator("answer_moves", "composition_guidance", "anti_template_rules", "must_include_claim_ids", "must_include_boundary_ids", mode="before")
|
||
@classmethod
|
||
def _normalize_plan_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=260) for item in rows[:12] if clean_text(item, limit=260)]
|
||
|
||
|
||
class PMFlowAIAgentTerminalAnswerState(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_TERMINAL_STATE_VERSION
|
||
visibility: Literal["customer"] = "customer"
|
||
user_goal: str = Field(default="", max_length=800)
|
||
terminal_status: Literal["answerable", "partially_answerable", "waiting_for_evidence", "waiting_for_user", "refused_by_boundary", "blocked"] = "answerable"
|
||
claims: list[PMFlowAIAgentClaim] = Field(default_factory=list)
|
||
boundaries: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
|
||
attempts: list[PMFlowAIAgentAttempt] = Field(default_factory=list)
|
||
action_candidates: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
|
||
deliverable_plan: PMFlowAIAgentDeliverablePlan | None = None
|
||
answer_objective: dict[str, Any] = Field(default_factory=dict)
|
||
dynamic_rubric: list[dict[str, Any]] = Field(default_factory=list)
|
||
deliverable_shape: dict[str, Any] = Field(default_factory=dict)
|
||
answer_intent_plan: PMFlowAIAgentAnswerIntentPlan = Field(default_factory=PMFlowAIAgentAnswerIntentPlan)
|
||
interpretation_decision: dict[str, Any] = Field(default_factory=dict)
|
||
remaining_gaps: list[str] = Field(default_factory=list)
|
||
next_possible_actions: list[str] = Field(default_factory=list)
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
|
||
@field_validator("user_goal", mode="before")
|
||
@classmethod
|
||
def _normalize_goal(cls, value: Any) -> str:
|
||
return clean_text(value, limit=800)
|
||
|
||
@field_validator("remaining_gaps", "next_possible_actions", mode="before")
|
||
@classmethod
|
||
def _normalize_terminal_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=360) for item in rows[:12] if clean_text(item, limit=360)]
|
||
|
||
|
||
class PMFlowAIAgentAnswerDraft(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
draft_id: str = Field(default_factory=lambda: new_agent_id("draft"), max_length=80)
|
||
answer_text: str = Field(default="", max_length=6000)
|
||
source: Literal[
|
||
"llm_composer",
|
||
"llm_composer_stream",
|
||
"llm_composer_hypothesis_lead_repair",
|
||
"llm_composer_readability_repair",
|
||
"llm_composer_stream_readability_repair",
|
||
"llm_reviser",
|
||
"terminal_fallback",
|
||
"terminal_repair",
|
||
] = "terminal_fallback"
|
||
quality_findings: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("draft_id", mode="before")
|
||
@classmethod
|
||
def _normalize_draft_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=6000)
|
||
|
||
@field_validator("answer_text", mode="before")
|
||
@classmethod
|
||
def _normalize_draft_answer_text(cls, value: Any) -> str:
|
||
return clean_markdown_text(value, limit=6000)
|
||
|
||
@field_validator("quality_findings", mode="before")
|
||
@classmethod
|
||
def _normalize_quality_findings(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentAnswerRuntimePacket(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_ANSWER_RUNTIME_PACKET_VERSION
|
||
objective: dict[str, Any] = Field(default_factory=dict)
|
||
terminal_state: PMFlowAIAgentTerminalAnswerState
|
||
draft: PMFlowAIAgentAnswerDraft
|
||
quality_findings: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("quality_findings", mode="before")
|
||
@classmethod
|
||
def _normalize_quality_findings(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentDeliverableSection(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
section_id: str = Field(default_factory=lambda: new_agent_id("dsec"), max_length=80)
|
||
section_kind: str = Field(default="summary", max_length=120)
|
||
display_title: str = Field(default="", max_length=160)
|
||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||
source_fact_ids: list[str] = Field(default_factory=list)
|
||
source_observation_ids: list[str] = Field(default_factory=list)
|
||
|
||
@field_validator("section_id", "section_kind", "display_title", mode="before")
|
||
@classmethod
|
||
def _normalize_section_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=160)
|
||
|
||
@field_validator("source_fact_ids", "source_observation_ids", mode="before")
|
||
@classmethod
|
||
def _normalize_section_refs(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:24] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentDeliverablePacket(BaseModel):
|
||
"""Customer-safe structured deliverable produced by the agent run."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_DELIVERABLE_PACKET_VERSION
|
||
visibility: Literal["customer"] = "customer"
|
||
deliverable_id: str = Field(default_factory=lambda: new_agent_id("deliv"), max_length=80)
|
||
deliverable_kind: str = Field(default="direct_answer", max_length=120)
|
||
status: str = Field(default="completed", max_length=80)
|
||
title: str = Field(default="", max_length=240)
|
||
summary: str = Field(default="", max_length=1200)
|
||
answer_text: str = Field(default="", max_length=6000)
|
||
sections: list[PMFlowAIAgentDeliverableSection] = Field(default_factory=list)
|
||
object_refs: list[str] = Field(default_factory=list)
|
||
source_fact_ids: list[str] = Field(default_factory=list)
|
||
source_observation_ids: list[str] = Field(default_factory=list)
|
||
pending_confirmations: list[dict[str, Any]] = Field(default_factory=list)
|
||
blocked_reasons: list[str] = Field(default_factory=list)
|
||
next_actions: list[str] = Field(default_factory=list)
|
||
confidence: str = Field(default="unknown", max_length=40)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("deliverable_id", "deliverable_kind", "status", "title", "summary", "confidence", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_packet_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=6000)
|
||
|
||
@field_validator("answer_text", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_answer_text(cls, value: Any) -> str:
|
||
return clean_markdown_text(value, limit=6000)
|
||
|
||
@field_validator("object_refs", "source_fact_ids", "source_observation_ids", "blocked_reasons", "next_actions", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_packet_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=360) for item in rows[:24] if clean_text(item, limit=360)]
|
||
|
||
|
||
class PMFlowAIAgentObjectiveLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
objective_id: str = Field(default_factory=lambda: new_agent_id("obj"), max_length=80)
|
||
goal: str = Field(default="", max_length=1200)
|
||
status: str = Field(default="active", max_length=80)
|
||
source: str = Field(default="user", max_length=80)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
updated_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("objective_id", "goal", "status", "source", mode="before")
|
||
@classmethod
|
||
def _normalize_objective_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1200)
|
||
|
||
|
||
class PMFlowAIAgentObjectLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
object_ref: str = Field(default="", max_length=160)
|
||
object_type: str = Field(default="", max_length=80)
|
||
title: str = Field(default="", max_length=240)
|
||
binding_strength: str = Field(default="", max_length=80)
|
||
href: str = Field(default="", max_length=300)
|
||
source: str = Field(default="", max_length=120)
|
||
available_affordances: list[str] = Field(default_factory=list)
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
last_seen_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("object_ref", "object_type", "title", "binding_strength", "href", "source", "source_observation_id", mode="before")
|
||
@classmethod
|
||
def _normalize_object_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=240)
|
||
|
||
@field_validator("available_affordances", mode="before")
|
||
@classmethod
|
||
def _normalize_affordance_names(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=120) for item in rows[:12] if clean_text(item, limit=120)]
|
||
|
||
|
||
class PMFlowAIAgentAffordanceLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
affordance_id: str = Field(default_factory=lambda: new_agent_id("aff"), max_length=80)
|
||
object_ref: str = Field(default="", max_length=160)
|
||
title: str = Field(default="", max_length=240)
|
||
action_label: str = Field(default="", max_length=240)
|
||
reason: str = Field(default="", max_length=600)
|
||
href: str = Field(default="", max_length=300)
|
||
safety: str = Field(default="suggest_only", max_length=80)
|
||
requires_confirmation: bool = True
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
evidence_refs: list[str] = Field(default_factory=list)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("affordance_id", "object_ref", "title", "action_label", "reason", "href", "safety", "source_observation_id", mode="before")
|
||
@classmethod
|
||
def _normalize_affordance_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=600)
|
||
|
||
@field_validator("evidence_refs", mode="before")
|
||
@classmethod
|
||
def _normalize_affordance_refs(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=180) for item in rows[:16] if clean_text(item, limit=180)]
|
||
|
||
|
||
class PMFlowAIAgentActionCandidateLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
candidate_id: str = Field(default_factory=lambda: new_agent_id("cand"), max_length=80)
|
||
title: str = Field(default="", max_length=240)
|
||
summary: str = Field(default="", max_length=800)
|
||
target_ref: str = Field(default="", max_length=160)
|
||
target_title: str = Field(default="", max_length=240)
|
||
href: str = Field(default="", max_length=300)
|
||
reason: str = Field(default="", max_length=800)
|
||
completion_criteria: str = Field(default="", max_length=800)
|
||
safety: str = Field(default="suggest_only", max_length=80)
|
||
requires_confirmation: bool = True
|
||
confidence: Literal["high", "medium", "low", "unknown"] = "unknown"
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
evidence_refs: list[str] = Field(default_factory=list)
|
||
blocked_reasons: list[str] = Field(default_factory=list)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator(
|
||
"candidate_id",
|
||
"title",
|
||
"summary",
|
||
"target_ref",
|
||
"target_title",
|
||
"href",
|
||
"reason",
|
||
"completion_criteria",
|
||
"safety",
|
||
"source_observation_id",
|
||
mode="before",
|
||
)
|
||
@classmethod
|
||
def _normalize_candidate_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=800)
|
||
|
||
@field_validator("evidence_refs", "blocked_reasons", mode="before")
|
||
@classmethod
|
||
def _normalize_candidate_lists(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=240) for item in rows[:16] if clean_text(item, limit=240)]
|
||
|
||
|
||
class PMFlowAIAgentDeliverablePlan(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
plan_id: str = Field(default_factory=lambda: new_agent_id("dplan"), max_length=80)
|
||
user_goal: str = Field(default="", max_length=800)
|
||
desired_customer_result: str = Field(default="", max_length=800)
|
||
composition_guidance: list[str] = Field(default_factory=list)
|
||
required_materials: list[str] = Field(default_factory=list)
|
||
specificity_requirements: list[str] = Field(default_factory=list)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("plan_id", "user_goal", "desired_customer_result", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_plan_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=800)
|
||
|
||
@field_validator("composition_guidance", "required_materials", "specificity_requirements", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_plan_lists(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=260) for item in rows[:16] if clean_text(item, limit=260)]
|
||
|
||
|
||
class PMFlowAIAgentFactLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
fact_id: str = Field(default_factory=lambda: new_agent_id("fact"), max_length=80)
|
||
fact_type: str = Field(default="", max_length=120)
|
||
fact_text: str = Field(default="", max_length=1200)
|
||
object_ref: str | None = Field(default=None, max_length=160)
|
||
source_tool: str | None = Field(default=None, max_length=80)
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
confidence: str = Field(default="unknown", max_length=40)
|
||
support_refs: list[str] = Field(default_factory=list)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("fact_id", "fact_type", "fact_text", "object_ref", "source_tool", "source_observation_id", "confidence", mode="before")
|
||
@classmethod
|
||
def _normalize_fact_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1200)
|
||
|
||
@field_validator("support_refs", mode="before")
|
||
@classmethod
|
||
def _normalize_fact_refs(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
return [clean_text(item, limit=160) for item in rows[:12] if clean_text(item, limit=160)]
|
||
|
||
|
||
class PMFlowAIAgentGapLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
gap_id: str = Field(default_factory=lambda: new_agent_id("gap"), max_length=80)
|
||
gap_type: str = Field(default="", max_length=120)
|
||
description: str = Field(default="", max_length=800)
|
||
blocking: bool = False
|
||
user_actionable: bool = False
|
||
source: str | None = Field(default=None, max_length=120)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("gap_id", "gap_type", "description", "source", mode="before")
|
||
@classmethod
|
||
def _normalize_gap_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=800)
|
||
|
||
|
||
class PMFlowAIAgentAttemptLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
attempt_id: str = Field(default_factory=lambda: new_agent_id("try"), max_length=80)
|
||
action: str = Field(default="", max_length=120)
|
||
tool_name: str | None = Field(default=None, max_length=80)
|
||
outcome: str = Field(default="", max_length=800)
|
||
observation_id: str | None = Field(default=None, max_length=80)
|
||
state_changed: bool = False
|
||
duration_ms: int | None = Field(default=None, ge=0)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("attempt_id", "action", "tool_name", "outcome", "observation_id", mode="before")
|
||
@classmethod
|
||
def _normalize_attempt_ledger_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=800)
|
||
|
||
|
||
class PMFlowAIAgentActionLedgerEntry(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
action_id: str = Field(default_factory=lambda: new_agent_id("act"), max_length=80)
|
||
action: str = Field(default="", max_length=120)
|
||
tool_name: str | None = Field(default=None, max_length=80)
|
||
status: str = Field(default="proposed", max_length=80)
|
||
rationale: str = Field(default="", max_length=1000)
|
||
expected_observation: str = Field(default="", max_length=1000)
|
||
requires_confirmation: bool = False
|
||
risk_level: str = Field(default="low", max_length=40)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("action_id", "action", "tool_name", "status", "rationale", "expected_observation", "risk_level", mode="before")
|
||
@classmethod
|
||
def _normalize_action_ledger_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1000)
|
||
|
||
|
||
class PMFlowAIAgentDeliverableState(BaseModel):
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
deliverable_kind: str = Field(default="answer", max_length=120)
|
||
status: str = Field(default="pending", max_length=80)
|
||
summary: str = Field(default="", max_length=1200)
|
||
stop_reason: str | None = Field(default=None, max_length=120)
|
||
source_observation_id: str | None = Field(default=None, max_length=80)
|
||
updated_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("deliverable_kind", "status", "summary", "stop_reason", "source_observation_id", mode="before")
|
||
@classmethod
|
||
def _normalize_deliverable_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1200)
|
||
|
||
|
||
class PMFlowAIAgentWorkspace(BaseModel):
|
||
"""Long-lived working memory for a PMFlow AI agent run."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
workspace_version: str = AGENT_WORKSPACE_VERSION
|
||
workspace_id: str = Field(default_factory=lambda: new_agent_id("aw"), max_length=80)
|
||
current_goal: str = Field(default="", max_length=1200)
|
||
goal_history: list[str] = Field(default_factory=list)
|
||
objective_ledger: list[PMFlowAIAgentObjectiveLedgerEntry] = Field(default_factory=list)
|
||
goal_frame: PMFlowAIAgentGoalFrame | None = None
|
||
object_ledger: list[PMFlowAIAgentObjectLedgerEntry] = Field(default_factory=list)
|
||
affordance_ledger: list[PMFlowAIAgentAffordanceLedgerEntry] = Field(default_factory=list)
|
||
action_candidate_ledger: list[PMFlowAIAgentActionCandidateLedgerEntry] = Field(default_factory=list)
|
||
deliverable_plan: PMFlowAIAgentDeliverablePlan | None = None
|
||
fact_ledger: list[PMFlowAIAgentFactLedgerEntry] = Field(default_factory=list)
|
||
claim_ledger: list[PMFlowAIAgentClaim] = Field(default_factory=list)
|
||
boundary_ledger: list[PMFlowAIAgentBoundary] = Field(default_factory=list)
|
||
gap_ledger: list[PMFlowAIAgentGapLedgerEntry] = Field(default_factory=list)
|
||
attempt_ledger: list[PMFlowAIAgentAttemptLedgerEntry] = Field(default_factory=list)
|
||
action_ledger: list[PMFlowAIAgentActionLedgerEntry] = Field(default_factory=list)
|
||
deliverable_state: PMFlowAIAgentDeliverableState = Field(default_factory=PMFlowAIAgentDeliverableState)
|
||
known_facts: list[dict[str, Any]] = Field(default_factory=list)
|
||
observations: list[PMFlowAIAgentObservation] = Field(default_factory=list)
|
||
evidence_normalizations: list[PMFlowAIAgentEvidenceNormalization] = Field(default_factory=list)
|
||
open_questions: list[str] = Field(default_factory=list)
|
||
assumptions: list[str] = Field(default_factory=list)
|
||
rejected_bindings: list[dict[str, Any]] = Field(default_factory=list)
|
||
evidence_requirements: list[str] = Field(default_factory=list)
|
||
candidate_next_actions: list[dict[str, Any]] = Field(default_factory=list)
|
||
answer_attempts: list[dict[str, Any]] = Field(default_factory=list)
|
||
verification_failures: list[dict[str, Any]] = Field(default_factory=list)
|
||
status: Literal["active", "waiting_for_tools", "waiting_for_user", "answered", "blocked"] = "active"
|
||
updated_at: str = Field(default_factory=utc_now_iso)
|
||
|
||
@field_validator("workspace_id", "current_goal", mode="before")
|
||
@classmethod
|
||
def _normalize_workspace_text(cls, value: Any) -> str:
|
||
return clean_text(value, limit=1200)
|
||
|
||
@field_validator("goal_history", "open_questions", "assumptions", "evidence_requirements", mode="before")
|
||
@classmethod
|
||
def _normalize_text_list(cls, value: Any) -> list[str]:
|
||
rows = value if isinstance(value, list) else []
|
||
out: list[str] = []
|
||
for item in rows:
|
||
text = clean_text(item, limit=360)
|
||
if text and text not in out:
|
||
out.append(text)
|
||
return out[:24]
|
||
|
||
|
||
class PMFlowAIAgentRunPacket(BaseModel):
|
||
"""Customer-safe summary of one agent run."""
|
||
|
||
model_config = ConfigDict(extra="ignore")
|
||
|
||
packet_version: str = AGENT_RUN_PACKET_VERSION
|
||
visibility: Literal["customer"] = "customer"
|
||
run_id: str = Field(default_factory=lambda: new_agent_id("arun"), max_length=80)
|
||
session_id: int | None = None
|
||
turn_index: int | None = None
|
||
status: Literal["active", "waiting_for_tools", "waiting_for_user", "answered", "blocked"] = "active"
|
||
user_goal: str = Field(default="", max_length=1200)
|
||
workspace_id: str = Field(default="", max_length=80)
|
||
run_budget: PMFlowAIAgentRunBudget = Field(default_factory=PMFlowAIAgentRunBudget)
|
||
stop_reason: str | None = Field(default=None, max_length=120)
|
||
iterations: int = Field(default=0, ge=0)
|
||
tool_call_count: int = Field(default=0, ge=0)
|
||
steps: list[PMFlowAIAgentStep] = Field(default_factory=list)
|
||
observations: list[PMFlowAIAgentObservation] = Field(default_factory=list)
|
||
event_log: list[PMFlowAIAgentRunEvent] = Field(default_factory=list)
|
||
created_at: str = Field(default_factory=utc_now_iso)
|
||
completed_at: str | None = None
|
||
|
||
@field_validator("run_id", "user_goal", "workspace_id", "stop_reason", mode="before")
|
||
@classmethod
|
||
def _normalize_run_text(cls, value: Any) -> str | None:
|
||
if value is None:
|
||
return None
|
||
return clean_text(value, limit=1200)
|
||
|
||
|
||
def normalize_agent_workspace_payload(value: Any, *, fallback_goal: str = "") -> PMFlowAIAgentWorkspace:
|
||
if isinstance(value, PMFlowAIAgentWorkspace):
|
||
return value
|
||
if isinstance(value, dict) and value:
|
||
try:
|
||
return PMFlowAIAgentWorkspace.model_validate(value)
|
||
except Exception:
|
||
pass
|
||
return PMFlowAIAgentWorkspace(current_goal=clean_text(fallback_goal, limit=1200))
|
||
|
||
|
||
def customer_process_packet_from_agent_steps(
|
||
steps: list[PMFlowAIAgentStep],
|
||
*,
|
||
observations: list[PMFlowAIAgentObservation] | None = None,
|
||
event_log: list[PMFlowAIAgentRunEvent] | None = None,
|
||
total_duration_ms: int | None = None,
|
||
) -> dict[str, Any]:
|
||
observation_by_id = {
|
||
str(item.observation_id): item
|
||
for item in list(observations or [])
|
||
if str(getattr(item, "observation_id", "") or "").strip()
|
||
}
|
||
event_summaries = _customer_event_summaries(event_log or [])
|
||
process_steps: list[dict[str, Any]] = []
|
||
for step in steps:
|
||
observation = observation_by_id.get(str(step.observation_id or ""))
|
||
tool_transcript = _customer_tool_transcript(step=step, observation=observation)
|
||
evidence = _customer_evidence_transcript(observation)
|
||
process_steps.append(
|
||
{
|
||
"key": step.key,
|
||
"label": step.label,
|
||
"status": step.status,
|
||
"display_summary": step.display_summary,
|
||
"duration_ms": step.duration_ms,
|
||
"blocking": step.blocking,
|
||
"observation_id": step.observation_id,
|
||
"process_narrative": _customer_process_narrative(step=step, observation=observation),
|
||
"activity_summary": _customer_activity_summary(step=step, observation=observation),
|
||
"tool_transcript": tool_transcript,
|
||
"evidence": evidence,
|
||
"boundary_summary": _customer_boundary_summary(observation),
|
||
}
|
||
)
|
||
return {
|
||
"packet_version": "customer_answer_process.v2",
|
||
"visibility": "customer",
|
||
"answer_type": "agent_run",
|
||
"route_display": "agent_runtime",
|
||
"steps": process_steps,
|
||
"event_summaries": event_summaries,
|
||
"total_duration_ms": max(0, int(total_duration_ms or 0)) if total_duration_ms is not None else None,
|
||
}
|
||
|
||
|
||
def _customer_event_summaries(events: list[PMFlowAIAgentRunEvent]) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for event in events[:24]:
|
||
summary = clean_text(event.summary, limit=260)
|
||
if not summary:
|
||
continue
|
||
out.append(
|
||
{
|
||
"event_type": event.event_type,
|
||
"summary": summary,
|
||
"duration_ms": event.duration_ms,
|
||
"state_changed": bool(event.state_changed),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _customer_process_narrative(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> str:
|
||
values = [
|
||
step.display_summary,
|
||
observation.summary if observation is not None else "",
|
||
]
|
||
for value in values:
|
||
text = _customer_visible_text(value, limit=360)
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def _customer_activity_summary(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> str:
|
||
if observation is None:
|
||
return _customer_visible_text(step.display_summary, limit=240)
|
||
fact_count = len([item for item in list(observation.facts or []) if isinstance(item, dict) and _customer_fact_visible(item)])
|
||
source_count = len([item for item in list(observation.sources or []) if isinstance(item, dict) and _safe_url(item.get("url") or item.get("source_url"))])
|
||
parts: list[str] = []
|
||
if source_count:
|
||
parts.append(f"记录了 {source_count} 个可追溯来源")
|
||
if fact_count:
|
||
parts.append(f"整理了 {fact_count} 条可见观察")
|
||
summary = _customer_visible_text(observation.summary, limit=220)
|
||
if summary:
|
||
parts.append(summary)
|
||
return ";".join(parts[:3])
|
||
|
||
|
||
def _customer_tool_transcript(*, step: PMFlowAIAgentStep, observation: PMFlowAIAgentObservation | None) -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
if step.selected_tool:
|
||
out.append(
|
||
{
|
||
"kind": "capability",
|
||
"title": "调用能力",
|
||
"summary": _customer_capability_label(step.selected_tool),
|
||
}
|
||
)
|
||
if observation is None:
|
||
return out
|
||
for fact in list(observation.facts or [])[:32]:
|
||
if not isinstance(fact, dict):
|
||
continue
|
||
fact_type = clean_text(fact.get("type"), limit=80)
|
||
if fact_type == "external_search_query":
|
||
query = _customer_visible_text(fact.get("query"), limit=240)
|
||
queries = [
|
||
_customer_visible_text(item, limit=180)
|
||
for item in list(fact.get("queries") or [])
|
||
if _customer_visible_text(item, limit=180)
|
||
][:6]
|
||
summary = ";".join(queries) if queries else query
|
||
if summary:
|
||
out.append({"kind": "search_query", "title": "搜索", "summary": summary})
|
||
continue
|
||
if fact_type == "external_retrieval_status":
|
||
count = fact.get("evidence_count")
|
||
status = _customer_visible_text(fact.get("status"), limit=80)
|
||
summary = f"返回 {count} 条候选来源" if isinstance(count, int) else status
|
||
if status and isinstance(count, int):
|
||
summary = f"{summary},检索状态:{status}"
|
||
if summary:
|
||
out.append({"kind": "tool_result", "title": "检索结果", "summary": summary})
|
||
continue
|
||
if fact_type == "external_source_unreadable":
|
||
url = _safe_url(fact.get("url"))
|
||
summary = _customer_visible_text(fact.get("summary"), limit=220) or "来源不可读,未进入最终判断。"
|
||
out.append({"kind": "unavailable_source", "title": "来源不可读", "summary": summary, "url": url})
|
||
continue
|
||
if fact_type == "visible_object_count":
|
||
value = fact.get("value")
|
||
if value is not None:
|
||
out.append({"kind": "space_read", "title": "读取当前空间", "summary": f"读取到 {value} 项可见内容。"})
|
||
continue
|
||
return _dedupe_process_items(out)[:16]
|
||
|
||
|
||
def _customer_evidence_transcript(observation: PMFlowAIAgentObservation | None) -> list[dict[str, Any]]:
|
||
if observation is None:
|
||
return []
|
||
out: list[dict[str, Any]] = []
|
||
for source in list(observation.sources or [])[:12]:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
url = _safe_url(source.get("url") or source.get("source_url"))
|
||
title = _customer_source_display_title(url=url, title=source.get("label") or source.get("title"), summary=source.get("summary") or source.get("snippet"))
|
||
summary = _customer_visible_text(source.get("summary") or source.get("snippet"), limit=260)
|
||
if url or title or summary:
|
||
out.append(
|
||
{
|
||
"kind": "source",
|
||
"title": title or _domain_label(url),
|
||
"summary": summary,
|
||
"url": url,
|
||
"domain": _domain_label(url),
|
||
}
|
||
)
|
||
for fact in list(observation.facts or [])[:32]:
|
||
if not isinstance(fact, dict) or not _customer_fact_visible(fact):
|
||
continue
|
||
fact_type = clean_text(fact.get("type"), limit=80)
|
||
if fact_type == "external_evidence":
|
||
url = _safe_url(fact.get("url") or fact.get("source_url"))
|
||
summary = _customer_visible_text(fact.get("summary") or fact.get("snippet"), limit=300)
|
||
title = _customer_source_display_title(url=url, title=fact.get("title"), summary=summary)
|
||
out.append({"kind": "search_result", "title": title, "summary": summary, "url": url, "domain": _domain_label(url)})
|
||
continue
|
||
if fact_type == "external_source_text":
|
||
url = _safe_url(fact.get("url") or fact.get("source_url"))
|
||
summary = _customer_source_excerpt(fact.get("text") or fact.get("summary"))
|
||
title = _customer_source_display_title(url=url, title=fact.get("title"), summary=summary)
|
||
out.append({"kind": "opened_source", "title": title, "summary": summary, "url": url, "domain": _domain_label(url)})
|
||
continue
|
||
if fact_type == "visible_object_samples":
|
||
items = []
|
||
for item in list(fact.get("items") or [])[:8]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
title = _customer_visible_text(item.get("title"), limit=120)
|
||
kind = _customer_visible_text(item.get("kind"), limit=40)
|
||
if title:
|
||
items.append({"title": title, "kind": kind})
|
||
if items:
|
||
out.append({"kind": "space_objects", "title": "当前空间可见内容", "items": items})
|
||
continue
|
||
return _dedupe_process_items(out)[:20]
|
||
|
||
|
||
def _customer_boundary_summary(observation: PMFlowAIAgentObservation | None) -> str:
|
||
if observation is None:
|
||
return ""
|
||
gaps: list[str] = []
|
||
for fact in list(observation.facts or [])[:32]:
|
||
if not isinstance(fact, dict):
|
||
continue
|
||
if clean_text(fact.get("type"), limit=80) in {"external_source_unreadable", "missing_external_query"}:
|
||
text = _customer_visible_text(fact.get("summary") or fact.get("url"), limit=220)
|
||
if text:
|
||
gaps.append(text)
|
||
for gap in list(getattr(observation, "gaps", []) or [])[:8]:
|
||
if isinstance(gap, dict):
|
||
text = _customer_visible_text(gap.get("summary") or gap.get("description"), limit=220)
|
||
else:
|
||
text = _customer_visible_text(gap, limit=220)
|
||
if text:
|
||
gaps.append(text)
|
||
return ";".join(_dedupe_text(gaps)[:3])
|
||
|
||
|
||
def _customer_fact_visible(fact: dict[str, Any]) -> bool:
|
||
fact_type = clean_text(fact.get("type"), limit=80)
|
||
if not fact_type or fact_type in {"agent_tool_contract"}:
|
||
return False
|
||
if fact_type.startswith("_"):
|
||
return False
|
||
return True
|
||
|
||
|
||
def _customer_visible_text(value: Any, *, limit: int = 300) -> str:
|
||
text = clean_text(value, limit=limit)
|
||
if not text:
|
||
return ""
|
||
lowered = text.lower()
|
||
blocked = (
|
||
"prompt",
|
||
"trace",
|
||
"provider",
|
||
"runtime",
|
||
"goal_frame",
|
||
"turn_state",
|
||
"evidence normalizer",
|
||
"evidencenormalizer",
|
||
"statecritic",
|
||
"function",
|
||
)
|
||
if any(token in lowered for token in blocked):
|
||
return ""
|
||
return text
|
||
|
||
|
||
def _customer_source_excerpt(value: Any) -> str:
|
||
text = _customer_visible_text(value, limit=700)
|
||
if not text:
|
||
return ""
|
||
for marker in (" Skip to content ", " Agree & Join ", " Your browser is not supported. "):
|
||
if marker in text:
|
||
text = text.split(marker, 1)[0].strip() or text.split(marker, 1)[-1].strip()
|
||
if len(text) <= 260:
|
||
return text
|
||
cut = text[:260].rstrip()
|
||
boundary = max(cut.rfind("。"), cut.rfind("."), cut.rfind(";"), cut.rfind(";"), cut.rfind(","), cut.rfind(","))
|
||
if boundary >= 80:
|
||
cut = cut[: boundary + 1].rstrip()
|
||
return f"{cut}..."
|
||
|
||
|
||
def _customer_source_display_title(*, url: Any, title: Any = "", summary: Any = "") -> str:
|
||
safe_title = _customer_visible_text(title, limit=180)
|
||
safe_url = _safe_url(url)
|
||
if safe_title and not safe_title.startswith(("http://", "https://")):
|
||
return safe_title
|
||
safe_summary = _customer_visible_text(summary, limit=180)
|
||
if safe_summary and not safe_summary.startswith(("http://", "https://")):
|
||
first = re.split(r"[。.!?!?]\s*", safe_summary, maxsplit=1)[0].strip()
|
||
if 8 <= len(first) <= 120:
|
||
return first
|
||
slug_title = _title_from_url_path(safe_url)
|
||
if slug_title:
|
||
return slug_title
|
||
return _domain_label(safe_url) or "公开来源"
|
||
|
||
|
||
def _title_from_url_path(url: str) -> str:
|
||
if not url:
|
||
return ""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except Exception:
|
||
return ""
|
||
path_parts = [part for part in parsed.path.split("/") if part]
|
||
if not path_parts:
|
||
return ""
|
||
candidate = unquote(path_parts[-1]).strip()
|
||
candidate = re.sub(r"\.(html?|php|aspx?)$", "", candidate, flags=re.IGNORECASE)
|
||
candidate = re.sub(r"[-_]+", " ", candidate)
|
||
candidate = re.sub(r"\s+", " ", candidate).strip(" -_/")
|
||
candidate = re.sub(r"\b[a-z0-9]{5,}$", "", candidate, flags=re.IGNORECASE).strip()
|
||
if not candidate:
|
||
return ""
|
||
if len(candidate) > 120:
|
||
candidate = candidate[:120].rsplit(" ", 1)[0].strip() or candidate[:120].strip()
|
||
return candidate
|
||
|
||
|
||
def _customer_capability_label(value: Any) -> str:
|
||
text = clean_text(value, limit=120).replace("_", " ").strip()
|
||
return text or "只读能力"
|
||
|
||
|
||
def _safe_url(value: Any) -> str:
|
||
text = clean_text(value, limit=500)
|
||
return text if text.startswith(("http://", "https://")) else ""
|
||
|
||
|
||
def _domain_label(url: str) -> str:
|
||
if not url:
|
||
return ""
|
||
try:
|
||
return urlparse(url).netloc.replace("www.", "")
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _dedupe_text(rows: list[str]) -> list[str]:
|
||
seen: set[str] = set()
|
||
out: list[str] = []
|
||
for row in rows:
|
||
text = clean_text(row, limit=300)
|
||
key = text.lower()
|
||
if not text or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(text)
|
||
return out
|
||
|
||
|
||
def _dedupe_process_items(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
seen: set[str] = set()
|
||
out: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
key = _process_item_dedupe_key(row)
|
||
if not key.strip("|") or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
out.append(row)
|
||
return out
|
||
|
||
|
||
def _process_item_dedupe_key(row: dict[str, Any]) -> str:
|
||
url_key = _normalized_source_url_key(row.get("url"))
|
||
if url_key:
|
||
return f"url:{url_key}"
|
||
items = row.get("items")
|
||
if isinstance(items, list) and items:
|
||
item_key = "|".join(
|
||
clean_text((item or {}).get("title"), limit=120).lower()
|
||
for item in items
|
||
if isinstance(item, dict) and clean_text(item.get("title"), limit=120)
|
||
)
|
||
if item_key:
|
||
return f"items:{item_key}"
|
||
return "|".join(str(row.get(name) or "") for name in ("kind", "title", "summary")).lower()
|
||
|
||
|
||
def _normalized_source_url_key(value: Any) -> str:
|
||
url = _safe_url(value)
|
||
if not url:
|
||
return ""
|
||
try:
|
||
parsed = urlparse(url)
|
||
except Exception:
|
||
return url.lower().rstrip("/")
|
||
path = re.sub(r"/+$", "", parsed.path or "/")
|
||
return f"{parsed.netloc.lower().replace('www.', '')}{path.lower()}"
|
||
|
||
|
||
__all__ = [
|
||
"PMFlowAIAgentAnswerDraft",
|
||
"PMFlowAIAgentAnswerIntentPlan",
|
||
"PMFlowAIAgentAnswerRuntimePacket",
|
||
"PMFlowAIAgentAttempt",
|
||
"PMFlowAIAgentBoundary",
|
||
"PMFlowAIAgentClaim",
|
||
"PMFlowAIAgentCriticResult",
|
||
"PMFlowAIAgentEvidenceNormalization",
|
||
"PMFlowAIAgentGoalFrame",
|
||
"PMFlowAIAgentActionLedgerEntry",
|
||
"PMFlowAIAgentActionCandidateLedgerEntry",
|
||
"PMFlowAIAgentAffordanceLedgerEntry",
|
||
"PMFlowAIAgentAttemptLedgerEntry",
|
||
"PMFlowAIAgentDeliverablePlan",
|
||
"PMFlowAIAgentDeliverablePacket",
|
||
"PMFlowAIAgentDeliverableSection",
|
||
"PMFlowAIAgentDeliverableState",
|
||
"PMFlowAIAgentFactLedgerEntry",
|
||
"PMFlowAIAgentGapLedgerEntry",
|
||
"PMFlowAIAgentObjectLedgerEntry",
|
||
"PMFlowAIAgentObjectiveLedgerEntry",
|
||
"PMFlowAIAgentObservation",
|
||
"PMFlowAIAgentRunBudget",
|
||
"PMFlowAIAgentRunEvent",
|
||
"PMFlowAIAgentRunPacket",
|
||
"PMFlowAIAgentStep",
|
||
"PMFlowAIAgentStepProposal",
|
||
"PMFlowAIAgentSourceNormalization",
|
||
"PMFlowAIAgentTerminalAnswerState",
|
||
"PMFlowAIAgentToolContract",
|
||
"PMFlowAIAgentWorkspace",
|
||
"customer_process_packet_from_agent_steps",
|
||
"new_agent_id",
|
||
"normalize_agent_workspace_payload",
|
||
]
|