397 lines
16 KiB
Python
397 lines
16 KiB
Python
"""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",
|
|
]
|