172 lines
5.5 KiB
Python
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)
|
|
|
|
|