"""Customer-visible answer presentation contracts.""" from __future__ import annotations import re from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator CustomerAnswerType = str CustomerConfidence = Literal["high", "medium", "low", "unknown"] PMFlowAIAnswerCanvasActionType = str CUSTOMER_EVIDENCE_PACKET_VERSION = "customer_evidence_packet.v1" _INTERNAL_DISPLAY_TERM_RE = re.compile( r"(" r"System Snapshot|User Snapshot|Session State|Project database|Knowledge Graph|Feishu history|" r"AICallLog|SemanticDecision|AdaptiveRAG|TurnState|ProblemFrame|EvidenceFindings|PROMPT_[A-Z0-9_]+|" r"problem_frame|ambiguity_points|interpretation_state|judgement_state|evidence_findings|runtime_steps|" r"\bprojects\b|\bteam_members\b|\bassigned_projects\b|\btenant_id\b|" r"\bsource_strategy\b|\bprompt_key\b|\btrace_id\b|" r"backend/|frontend/|app/services|字段|数组|快照|上下文明确|一致指向" r")", re.IGNORECASE, ) def _clean_text(value: Any, *, limit: int = 500) -> str: return " ".join(str(value or "").strip().split())[:limit] def _clean_customer_display(value: Any, *, limit: int = 500) -> str: text = _clean_text(value, limit=limit) if not text: return "" return _INTERNAL_DISPLAY_TERM_RE.sub("", text).strip() def _clean_customer_markdown(value: Any, *, limit: int = 500) -> str: text = str(value or "").strip() if not text: return "" cleaned_lines = [] for raw in text.splitlines(): line = " ".join(raw.rstrip().split()) cleaned_lines.append(_INTERNAL_DISPLAY_TERM_RE.sub("", line).strip()) return re.sub(r"\n{3,}", "\n\n", "\n".join(cleaned_lines)).strip()[:limit] def _clean_customer_object_anchor(value: Any) -> dict[str, Any] | None: if not isinstance(value, dict): return None object_ref = _clean_customer_display(value.get("object_ref") or value.get("objectRef"), limit=120) object_type = _clean_customer_display(value.get("object_type") or value.get("objectType"), limit=60) object_id = _clean_customer_display(value.get("object_id") or value.get("objectId"), limit=120) if not object_ref and object_type and object_id: object_ref = f"{object_type}:{object_id}" if not object_ref: return None return { "object_ref": object_ref, "object_type": object_type, "object_id": object_id, "surface": _clean_customer_display(value.get("surface"), limit=80), "project_id": _clean_customer_display(value.get("project_id") or value.get("projectId"), limit=80), "project_title": _clean_customer_display(value.get("project_title") or value.get("projectTitle"), limit=160), "href": _clean_customer_display(value.get("href"), limit=240), "parent_object_ref": _clean_customer_display( value.get("parent_object_ref") or value.get("parentObjectRef"), limit=120, ) or None, } class CustomerEvidenceFact(BaseModel): """One customer-safe fact used by the final answer composer.""" type: str = Field(..., min_length=1, max_length=64) display_label: str = Field(default="", max_length=160) display_summary: str = Field(default="", max_length=1200) business_values: dict[str, Any] = Field(default_factory=dict) source_display_labels: list[str] = Field(default_factory=list) confidence: CustomerConfidence = "unknown" action_hint: str | None = Field(default=None, max_length=240) @field_validator("display_label", "display_summary", "action_hint", mode="before") @classmethod def _sanitize_display_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=1200) @field_validator("source_display_labels", mode="before") @classmethod def _sanitize_source_labels(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows: text = _clean_customer_display(item, limit=80) if text and text not in out: out.append(text) return out[:6] @field_validator("business_values", mode="before") @classmethod def _sanitize_business_values(cls, value: Any) -> dict[str, Any]: if not isinstance(value, dict): return {} allowed: dict[str, Any] = {} for key, raw in value.items(): display_key = _clean_customer_display(key, limit=40) if not display_key: continue if isinstance(raw, (int, float, bool)) or raw is None: allowed[display_key] = raw elif isinstance(raw, list): allowed[display_key] = [ _clean_customer_display(item, limit=160) for item in raw[:12] if _clean_customer_display(item, limit=160) ] else: display_value = _clean_customer_display(raw, limit=240) if display_value: allowed[display_key] = display_value return allowed @model_validator(mode="after") def _ensure_summary(self) -> "CustomerEvidenceFact": if not self.display_summary and self.display_label: self.display_summary = self.display_label return self class CustomerSuggestedAction(BaseModel): title: str = Field(..., min_length=1, max_length=160) project: str | None = Field(default=None, max_length=120) priority: str | None = Field(default=None, max_length=60) href: str | None = Field(default=None, max_length=240) object_ref: str | None = Field(default=None, max_length=120) action_type: str | None = Field(default=None, max_length=80) object_type: str | None = Field(default=None, max_length=80) surface: str | None = Field(default=None, max_length=80) object_anchor: dict[str, Any] | None = None @field_validator("title", "project", "priority", "href", "object_ref", "action_type", "object_type", "surface", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=160) @field_validator("object_anchor", mode="before") @classmethod def _sanitize_object_anchor(cls, value: Any) -> dict[str, Any] | None: return _clean_customer_object_anchor(value) class CustomerPresentationRules(BaseModel): hide_internal_terms: bool = True customer_language: str = "zh-CN" no_raw_source_labels: bool = True no_execution_claim: bool = True class CustomerVisibleObject(BaseModel): object_type: str = Field(..., min_length=1, max_length=64) object_ref: str = Field(..., min_length=1, max_length=120) object_id: int | None = None title: str = Field(..., min_length=1, max_length=160) status: str | None = Field(default=None, max_length=80) href: str | None = Field(default=None, max_length=240) display_summary: str = Field(default="", max_length=800) @field_validator("object_type", "object_ref", "title", "status", "display_summary", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=800) class CustomerActiveWorkspaceSummary(BaseModel): workspace_kind: str = Field(..., min_length=1, max_length=64) topic: str = Field(default="", max_length=120) primary_object_ref: str | None = Field(default=None, max_length=120) primary_object_title: str | None = Field(default=None, max_length=160) candidate_set_binding: str | None = Field(default=None, max_length=120) candidate_count: int = Field(default=0, ge=0, le=50) status: str | None = Field(default=None, max_length=32) @field_validator( "workspace_kind", "topic", "primary_object_ref", "primary_object_title", "candidate_set_binding", "status", mode="before", ) @classmethod def _sanitize_workspace_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=200) class CustomerInteractionContextPacket(BaseModel): packet_version: str = "customer_interaction_context.v1" visibility: Literal["customer"] = "customer" current_focus_object: CustomerVisibleObject | None = None visible_objects: list[CustomerVisibleObject] = Field(default_factory=list) last_shown_objects: list[CustomerVisibleObject] = Field(default_factory=list) active_workspace: CustomerActiveWorkspaceSummary | None = None class CustomerAnswerProcessStep(BaseModel): key: str = Field(..., min_length=1, max_length=80) label: str = Field(..., min_length=1, max_length=120) status: Literal["pending", "active", "completed", "skipped"] = "completed" display_summary: str | None = Field(default=None, max_length=240) duration_ms: int | None = Field(default=None, ge=0) blocking: bool = True @field_validator("key", "label", "display_summary", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=240) class CustomerAnswerProcessPacket(BaseModel): packet_version: str = "customer_answer_process.v1" visibility: Literal["customer"] = "customer" answer_type: CustomerAnswerType = "general_answer" route_display: str | None = Field(default=None, max_length=120) steps: list[CustomerAnswerProcessStep] = Field(default_factory=list) total_duration_ms: int | None = Field(default=None, ge=0) class PMFlowAIAnswerCanvasEvidenceBlock(BaseModel): label: str = Field(..., min_length=1, max_length=160) summary: str = Field(..., min_length=1, max_length=1000) source_type: Literal["space", "project", "memory", "execution", "external", "pmflow"] = "pmflow" confidence: CustomerConfidence = "unknown" @field_validator("label", "summary", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str: return _clean_customer_display(value, limit=1000) class PMFlowAIAnswerCanvasObjectRef(BaseModel): object_ref: str = Field(..., min_length=1, max_length=120) title: str = Field(..., min_length=1, max_length=160) type: str = Field(..., min_length=1, max_length=64) href: str | None = Field(default=None, max_length=240) status: str | None = Field(default=None, max_length=80) summary: str | None = Field(default=None, max_length=500) @field_validator("object_ref", "title", "type", "href", "status", "summary", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=500) class PMFlowAIAnswerCanvasNextAction(BaseModel): title: str = Field(..., min_length=1, max_length=160) reason: str = Field(default="", max_length=500) action_type: PMFlowAIAnswerCanvasActionType = "none" href: str | None = Field(default=None, max_length=240) object_ref: str | None = Field(default=None, max_length=120) object_anchor: dict[str, Any] | None = None requires_user_confirm: bool = True @field_validator("title", "reason", "href", "object_ref", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=500) @field_validator("object_anchor", mode="before") @classmethod def _sanitize_object_anchor(cls, value: Any) -> dict[str, Any] | None: return _clean_customer_object_anchor(value) class PMFlowAIAnswerCanvas(BaseModel): packet_version: str = "pmflow_ai_answer_canvas.v1" visibility: Literal["customer"] = "customer" headline: str = Field(default="", max_length=160) direct_answer: str = Field(default="", max_length=2000) evidence_blocks: list[PMFlowAIAnswerCanvasEvidenceBlock] = Field(default_factory=list) object_refs: list[PMFlowAIAnswerCanvasObjectRef] = Field(default_factory=list) next_actions: list[PMFlowAIAnswerCanvasNextAction] = Field(default_factory=list) limitations: list[str] = Field(default_factory=list) @field_validator("headline", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str: return _clean_customer_display(value, limit=2000) @field_validator("direct_answer", mode="before") @classmethod def _sanitize_direct_answer(cls, value: Any) -> str: return _clean_customer_markdown(value, limit=2000) @field_validator("limitations", mode="before") @classmethod def _sanitize_limitations(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows[:8]: text = _clean_customer_display(item, limit=160) if text and text not in out: out.append(text) return out class CustomerEvidencePacket(BaseModel): """The only evidence shape allowed to feed customer-visible answer composition.""" packet_version: str = CUSTOMER_EVIDENCE_PACKET_VERSION visibility: Literal["customer"] = "customer" tenant_id: int | None = None user_id: int | None = None question: str = Field(default="", max_length=500) answer_type: CustomerAnswerType = "general_answer" route_display: str | None = Field(default=None, max_length=120) facts: list[CustomerEvidenceFact] = Field(default_factory=list) source_summary: list[str] = Field(default_factory=list) confidence: CustomerConfidence = "unknown" limitations: list[str] = Field(default_factory=list) suggested_actions: list[CustomerSuggestedAction] = Field(default_factory=list) fallback_summary: str = Field(default="", max_length=1200) presentation_rules: CustomerPresentationRules = Field(default_factory=CustomerPresentationRules) @field_validator("question", "fallback_summary", "route_display", mode="before") @classmethod def _sanitize_text(cls, value: Any) -> str | None: if value is None: return None return _clean_customer_display(value, limit=1200) @field_validator("source_summary", "limitations", mode="before") @classmethod def _sanitize_text_list(cls, value: Any) -> list[str]: rows = value if isinstance(value, list) else [] out: list[str] = [] for item in rows: text = _clean_customer_display(item, limit=120) if text and text not in out: out.append(text) return out[:8] def to_customer_prompt_json(self) -> dict[str, Any]: return self.model_dump(mode="json", exclude_none=True, exclude={"tenant_id", "user_id"}) class CustomerAnswerVerification(BaseModel): ok: bool = False origin: str = "deterministic_guard" leak_found: bool = False untranslated_terms: list[str] = Field(default_factory=list) unanswered: bool = False overclaim: bool = False rewrite_required: bool = True reason: str | None = None __all__ = [ "CUSTOMER_EVIDENCE_PACKET_VERSION", "CustomerAnswerType", "CustomerAnswerVerification", "CustomerAnswerProcessPacket", "CustomerAnswerProcessStep", "CustomerEvidenceFact", "CustomerEvidencePacket", "CustomerInteractionContextPacket", "PMFlowAIAnswerCanvas", "PMFlowAIAnswerCanvasEvidenceBlock", "PMFlowAIAnswerCanvasNextAction", "PMFlowAIAnswerCanvasObjectRef", "CustomerActiveWorkspaceSummary", "CustomerPresentationRules", "CustomerSuggestedAction", "CustomerVisibleObject", ]