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

268 lines
13 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._heuristic_project_prefill_fallback(message)
return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback")
async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]:
if self._is_simple_signal_source_request(message):
fallback = self._heuristic_signal_fallback(message=message, projects=projects)
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath")
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._heuristic_signal_fallback(message=message, projects=projects)
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback")
async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]:
if self._is_simple_signal_source_request(message):
fallback = self._heuristic_level_fallback(message)
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath")
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,
}
if self._is_simple_signal_source_request(message):
payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35)
payload["level"] = "signal"
payload["should_split"] = False
payload["split_items"] = []
payload["level_reasoning"] = (
f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。"
)[:600]
return _validate_schema(_LevelClassificationSchema, payload, context="level_classification")
except Exception as exc:
logger.warning(f"feishu_level_classification_fallback: error={exc}")
fallback = self._heuristic_level_fallback(message)
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback")
def _heuristic_level_fallback(self, message: str) -> dict[str, Any]:
text = message or ""
build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2
scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35
timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1
owner_score = 0.7 if OWNER_RE.search(text) else 0.15
project_score = round(
(build_intent_score * 0.4)
+ (scope_score * 0.25)
+ (timeline_score * 0.2)
+ (owner_score * 0.15),
4,
)
has_project_intent = bool(PROJECT_INTENT_RE.search(text))
has_signal_fact = bool(SIGNAL_FACT_RE.search(text))
should_split = bool(has_project_intent and has_signal_fact)
simple_signal_request = self._is_simple_signal_source_request(text)
if simple_signal_request:
project_score = min(project_score, 0.35)
should_split = False
level = "signal"
level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。"
else:
level = "project" if project_score >= 0.6 else "signal"
level_reasoning = "基于关键词与上下文规则的兜底判定。"
return {
"build_intent_score": build_intent_score,
"scope_score": scope_score,
"timeline_score": timeline_score,
"owner_score": owner_score,
"project_score": project_score,
"level": level,
"level_confidence": max(project_score, 1 - project_score),
"level_reasoning": level_reasoning,
"should_split": should_split,
"split_items": (
[
{"level": "project", "content": text},
{"level": "signal", "content": text},
]
if should_split
else []
),
}
@staticmethod
def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]:
normalized_message = str(message or "").strip().lower()
if not normalized_message:
return None
for row in projects:
if not isinstance(row, dict):
continue
pid_raw = row.get("id")
title = str(row.get("name") or row.get("title") or "").strip()
if not title or len(title) < 2:
continue
try:
pid = int(pid_raw) if pid_raw is not None else None
except Exception:
pid = None
if pid is None:
continue
if title.lower() in normalized_message:
return pid
return None
def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]:
text = str(message or "").strip()
normalized = text.lower()
cleaned = re.sub(r"\s+", " ", text).strip()
summary = cleaned[:280] if cleaned else "飞书消息(空内容)"
signal_type = "新需求"
priority = "P2"
recommended_action = "enter_only"
if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")):
signal_type = "项目风险"
priority = "P1"
recommended_action = "trigger_alert"
elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")):
signal_type = "用户反馈"
priority = "P2"
elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")):
signal_type = "竞品动态"
priority = "P2"
title_seed = cleaned or "飞书需求信号"
title = title_seed[:48]
if not title:
title = "飞书需求信号"
confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72
return {
"title": title,
"type": signal_type,
"priority": priority,
"matched_project_id": self._guess_matched_project_id_from_text(text, projects),
"summary": summary,
"reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"],
"confidence": confidence,
"recommended_action": recommended_action,
}
@staticmethod
def _heuristic_project_prefill_fallback(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_owner": None,
"suggested_timeline": None,
"confidence": 0.35,
"reasoning": [
"LLM 返回异常,已按规则生成项目预填草案。",
"负责人和时间线仅在消息明确给出时才填写。",
],
}