PMFlow/backend/app/services/feishu_signal_pipeline/shared_schema_utils.py

172 lines
5.5 KiB
Python

class _LevelSplitItemSchema(BaseModel):
level: Literal["project", "signal"]
content: str = Field(min_length=1, max_length=4000)
class _LevelClassificationSchema(BaseModel):
build_intent_score: float = Field(ge=0.0, le=1.0)
scope_score: float = Field(ge=0.0, le=1.0)
timeline_score: float = Field(ge=0.0, le=1.0)
owner_score: float = Field(ge=0.0, le=1.0)
project_score: float = Field(ge=0.0, le=1.0)
level: Literal["project", "signal"]
level_confidence: float = Field(ge=0.0, le=1.0)
level_reasoning: str = Field(min_length=1, max_length=600)
should_split: bool = False
split_items: list[_LevelSplitItemSchema] = Field(default_factory=list, max_length=2)
class _SignalExtractionSchema(BaseModel):
title: str = Field(min_length=1, max_length=200)
type: Literal["竞品动态", "用户反馈", "项目风险", "新需求"]
priority: Literal["P0", "P1", "P2", "P3"]
matched_project_id: Optional[int] = None
summary: str = Field(min_length=1, max_length=3000)
reasoning: list[str] = Field(min_length=1, max_length=3)
confidence: float = Field(ge=0.0, le=1.0)
recommended_action: Literal["enter_only", "create_charter", "trigger_alert"]
class _ProjectPrefillSchema(BaseModel):
suggested_name: str = Field(min_length=1, max_length=255)
suggested_description: str = Field(min_length=1, max_length=4000)
suggested_owner: str = Field(min_length=1, max_length=120)
suggested_timeline: str = Field(min_length=1, max_length=120)
confidence: float = Field(ge=0.0, le=1.0)
reasoning: list[str] = Field(min_length=1, max_length=3)
def _normalize_reasoning(value: Any, max_items: int = 3) -> list[str]:
if isinstance(value, list):
rows = [str(x).strip() for x in value if str(x).strip()]
return rows[:max_items]
if isinstance(value, str):
parts = [p.strip(" -•\t") for p in value.splitlines() if p.strip()]
return parts[:max_items]
return []
def _safe_json_object(value: str) -> dict[str, Any]:
raw = (value or "").strip()
if raw.startswith("```"):
chunks = raw.split("```")
if len(chunks) >= 2:
raw = chunks[1].strip()
if raw.startswith("json"):
raw = raw[4:].strip()
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
def _validate_schema(
schema: type[BaseModel],
payload: dict[str, Any],
*,
context: str,
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError(f"{context}_schema_invalid: payload_not_object")
try:
if hasattr(schema, "model_validate"):
model = schema.model_validate(payload) # type: ignore[attr-defined]
else:
model = schema.parse_obj(payload) # type: ignore[attr-defined]
except ValidationError as exc:
raise ValueError(f"{context}_schema_invalid: {str(exc)[:400]}") from exc
if hasattr(model, "model_dump"):
return model.model_dump() # type: ignore[no-any-return]
return model.dict() # type: ignore[no-any-return]
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return float(default)
def _safe_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y", "on", "是", "真"}:
return True
if text in {"0", "false", "no", "n", "off", "否", "假"}:
return False
return default
def _safe_int(value: Any) -> Optional[int]:
if isinstance(value, bool):
return None
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def _normalize_level_value(value: Any, default: str = "signal") -> str:
text = str(value or "").strip().lower()
if not text:
return default
if text in {"project", "project-level", "project_level", "new_project"}:
return "project"
if text in {"signal", "signal-level", "signal_level"}:
return "signal"
if text in {"项目", "项目级", "立项", "新项目"}:
return "project"
if text in {"信号", "信号级", "需求", "需求级", "事件"}:
return "signal"
if "project" in text or "项目" in text or "立项" in text:
return "project"
if "signal" in text or "信号" in text or "需求" in text:
return "signal"
return default
def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
for key in keys:
if key not in data:
continue
value = data.get(key)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
return value
return None
def _utc_now() -> datetime:
return now_cn_aware()
def _compact_text(value: Any, max_len: int = 160) -> str:
text = str(value or "").strip()
if len(text) <= max_len:
return text
return f"{text[: max_len - 3]}..."
def _safe_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str):
return _safe_json_object(value)
return {}
def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is not None:
value = value.astimezone(CN_TZ).replace(tzinfo=None)
return value + timedelta(minutes=minutes)