PMFlow/backend/app/schemas/customer_answer.py

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