PMFlow/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py

198 lines
9.3 KiB
Python

"""Feishu signal pipeline mixin."""
from __future__ import annotations
from .shared import *
class FeishuPipelineExtractionMixin:
async def _extract_project_prefill(self, message: str) -> dict[str, Any]:
prompt = PROMPT_FEISHU_PROJECT_PREFILL.format(message=message[:1500])
try:
raw = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="feishu_project_prefill",
)
parsed = _safe_json_object(raw)
if not parsed:
raise ValueError("project_prefill_empty_json")
parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning"))
return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill")
except Exception as exc:
logger.warning(f"feishu_project_prefill_fallback: error={exc}")
fallback = self._build_project_prefill_degraded(message=message)
return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback")
async def _extract_signal(
self,
message: str,
*,
projects: list[dict[str, Any]],
signal_intake_hint: bool = False,
) -> dict[str, Any]:
prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format(
message=message[:1500],
projects_json=json.dumps(projects, ensure_ascii=False),
)
try:
raw = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="feishu_signal_extraction",
)
parsed = _safe_json_object(raw)
if not parsed:
raise ValueError("signal_extraction_empty_json")
parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning"))
return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction")
except Exception as exc:
logger.warning(f"feishu_signal_extraction_fallback: error={exc}")
fallback = self._build_signal_extraction_degraded(
message=message,
signal_intake_hint=signal_intake_hint,
)
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback")
async def _classify_level(
self,
*,
message: str,
projects: list[dict[str, Any]],
signal_intake_hint: bool = False,
) -> dict[str, Any]:
prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format(
message=message[:1500],
projects_json=json.dumps(projects, ensure_ascii=False),
)
try:
raw = await self.llm.generate(
prompt,
db=self.db,
tenant_id=self.tenant_id,
function_name="feishu_level_classification",
)
parsed = _safe_json_object(raw)
if not parsed:
raise ValueError("level_classification_empty_json")
# 兼容不同模型输出别名,避免只返回总分/中文 level 时被误降级到 signal。
bi_raw = _first_non_empty(parsed, ("build_intent_score", "buildIntentScore", "intent_score", "build_intent"))
sc_raw = _first_non_empty(parsed, ("scope_score", "scopeScore", "scope"))
tl_raw = _first_non_empty(parsed, ("timeline_score", "timelineScore", "timeline"))
ow_raw = _first_non_empty(parsed, ("owner_score", "ownerScore", "owner"))
provided_project_score_raw = _first_non_empty(parsed, ("project_score", "projectScore", "score"))
bi = _safe_float(bi_raw, 0.0)
sc = _safe_float(sc_raw, 0.0)
tl = _safe_float(tl_raw, 0.0)
ow = _safe_float(ow_raw, 0.0)
weighted = round((bi * 0.4) + (sc * 0.25) + (tl * 0.2) + (ow * 0.15), 4)
component_count = sum(value is not None for value in (bi_raw, sc_raw, tl_raw, ow_raw))
has_project_score = provided_project_score_raw is not None
provided_project_score = _safe_float(provided_project_score_raw, 0.0)
final_project_score = weighted
if component_count < 4 and has_project_score:
final_project_score = provided_project_score
elif component_count > 0 and has_project_score:
# 当模型给出了总分与维度分,优先采用更保守的较高值,降低误判为 signal 的风险。
final_project_score = max(weighted, provided_project_score)
final_project_score = max(0.0, min(1.0, final_project_score))
default_level = "project" if final_project_score >= 0.6 else "signal"
normalized_split_items: list[dict[str, str]] = []
raw_split_items = parsed.get("split_items")
if isinstance(raw_split_items, list):
for item in raw_split_items[:2]:
if not isinstance(item, dict):
continue
item_level = _normalize_level_value(item.get("level"), default=default_level)
item_content = str(item.get("content") or "").strip()
if not item_content:
continue
normalized_split_items.append({"level": item_level, "content": item_content})
level_reasoning = str(parsed.get("level_reasoning") or "").strip() or "模型返回缺少理由,已按结构化兜底。"
payload: dict[str, Any] = {
"build_intent_score": max(0.0, min(1.0, bi)),
"scope_score": max(0.0, min(1.0, sc)),
"timeline_score": max(0.0, min(1.0, tl)),
"owner_score": max(0.0, min(1.0, ow)),
"project_score": final_project_score,
"level": _normalize_level_value(parsed.get("level"), default=default_level),
"level_confidence": max(
0.0,
min(1.0, _safe_float(parsed.get("level_confidence"), max(final_project_score, 1 - final_project_score))),
),
"level_reasoning": level_reasoning[:600],
"should_split": _safe_bool(parsed.get("should_split"), False),
"split_items": normalized_split_items,
}
return _validate_schema(_LevelClassificationSchema, payload, context="level_classification")
except Exception as exc:
logger.warning(f"feishu_level_classification_fallback: error={exc}")
fallback = self._build_level_classification_degraded(signal_intake_hint=signal_intake_hint)
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback")
@staticmethod
def _build_level_classification_degraded(*, signal_intake_hint: bool) -> dict[str, Any]:
project_score = 0.2 if signal_intake_hint else 0.3
return {
"build_intent_score": 0.0,
"scope_score": 0.0,
"timeline_score": 0.0,
"owner_score": 0.0,
"project_score": project_score,
"level": "signal",
"level_confidence": 0.35 if signal_intake_hint else 0.25,
"level_reasoning": "结构化分类暂时不可用,当前先按低置信信号待确认处理。",
"should_split": False,
"split_items": [],
}
@staticmethod
def _build_signal_extraction_degraded(*, message: str, signal_intake_hint: bool) -> dict[str, Any]:
text = str(message or "").strip()
cleaned = re.sub(r"\s+", " ", text).strip()
summary = cleaned[:280] if cleaned else "原始飞书消息暂未解析出稳定信号结构。"
title = (cleaned[:48] or "飞书需求信号").strip() or "飞书需求信号"
return {
"title": title[:200],
"type": "新需求",
"priority": "P2",
"matched_project_id": None,
"summary": summary,
"reasoning": ["结构化信号提取失败,当前仅保留原始消息,待确认后再绑定正式对象。"],
"confidence": 0.35 if signal_intake_hint else 0.3,
"recommended_action": "enter_only",
}
@staticmethod
def _build_project_prefill_degraded(*, message: str) -> dict[str, Any]:
text = str(message or "").strip()
cleaned = re.sub(r"\s+", " ", text).strip()
suggested_name = cleaned[:30] if cleaned else "飞书新需求项目"
if len(suggested_name) < 4:
suggested_name = "飞书新需求项目"
suggested_description = (
f"由飞书需求自动生成的项目草案:{cleaned[:200]}"
if cleaned
else "由飞书需求自动生成的项目草案,待补充目标、范围与里程碑。"
)
return {
"suggested_name": suggested_name,
"suggested_description": suggested_description,
"suggested_scope": None,
"suggested_owner": None,
"suggested_timeline": None,
"confidence": 0.35,
"reasoning": [
"结构化项目预填失败,当前仅保留原始描述,待补充标题、范围和负责人。",
"负责人和时间线仅在消息明确给出时才填写。",
],
}