PMFlow/backend/app/services/analysis_packet_builder.py

1236 lines
54 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Shared analysis packet builder for analysis-style replies."""
from __future__ import annotations
import os
from typing import Any
from app.schemas.analysis_packets import (
AnalysisFocusObject,
ExternalImpactAnalysisPacket,
FollowupExplainAnalysisPacket,
InternalExplainAnalysisPacket,
ProjectFitAnalysisPacket,
normalize_analysis_packet_payload,
)
_ENV_ENABLE_ANALYSIS_PACKET_FAMILY = "PMFLOW_ENABLE_ANALYSIS_PACKET_FAMILY"
def _env_bool(name: str, default: bool = False) -> bool:
raw = str(os.getenv(name, "") or "").strip().lower()
if not raw:
return bool(default)
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return bool(default)
def _safe_dict(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _internal_packet_section(packet: Any, key: str) -> dict[str, Any]:
payload = _safe_dict(packet)
value = payload.get(key)
return dict(value) if isinstance(value, dict) else {}
def _normalize_text(value: Any, *, max_len: int = 240) -> str:
text = " ".join(str(value or "").split()).strip()
if not text:
return ""
return text[:max_len]
def _normalize_object_id(value: Any) -> int | None:
try:
return int(value) if value is not None else None
except Exception:
return None
def _normalize_string_list(value: Any, *, limit: int = 6, max_len: int = 120) -> list[str]:
if not isinstance(value, list):
return []
rows: list[str] = []
for item in value:
text = _normalize_text(item, max_len=max_len)
if text and text not in rows:
rows.append(text)
if len(rows) >= limit:
break
return rows
def _normalize_preferred_names(
preferred_names: list[Any] | None,
*,
limit: int = 6,
max_len: int = 220,
) -> list[str]:
if not isinstance(preferred_names, list):
return []
rows: list[str] = []
for item in preferred_names:
text = _normalize_text(item, max_len=max_len)
if not text or text in rows:
continue
rows.append(text)
if len(rows) >= limit:
break
return rows
def _normalize_route_mode(value: Any) -> str:
text = str(value or "").strip().lower()
if text in {"internal", "external", "uncertain"}:
return text
return "uncertain"
def _first_non_empty(*values: Any, max_len: int = 240) -> str:
for value in values:
text = _normalize_text(value, max_len=max_len)
if text:
return text
return ""
def _project_subject(
*,
project_snapshot: dict[str, Any],
fit_brief: dict[str, Any],
project_operating_context: dict[str, Any],
) -> str:
return _first_non_empty(
project_snapshot.get("project_name"),
fit_brief.get("project_name"),
fit_brief.get("project_summary"),
project_operating_context.get("project_name"),
project_operating_context.get("current_repo_scope"),
max_len=120,
)
def _normalize_entity_detail(value: Any) -> str:
text = _normalize_text(value, max_len=220)
if not text:
return ""
for marker in ("上下文:", "上下文:", "context:", "Context:"):
if marker in text:
text = text.split(marker, 1)[0].rstrip("。;;,, ")
break
return _normalize_text(text, max_len=220)
def _iter_structured_snapshot_entities(
project_snapshot: dict[str, Any],
*,
key: str,
kind: str,
) -> list[dict[str, str]]:
rows = project_snapshot.get(key)
if not isinstance(rows, list):
return []
normalized: list[dict[str, str]] = []
for item in rows[:6]:
if not isinstance(item, dict):
continue
name = _first_non_empty(
item.get("title"),
item.get("name"),
item.get("display_name"),
max_len=120,
)
if not name:
continue
detail = _first_non_empty(
item.get("description"),
item.get("summary"),
item.get("reasoning"),
item.get("content"),
max_len=220,
)
normalized.append(
{
"name": name,
"kind": kind,
"source": f"project_snapshot_{kind}",
"object_id": _normalize_object_id(item.get("id") or item.get("object_id")),
"detail": detail,
"status": _normalize_text(item.get("status"), max_len=40),
}
)
return normalized
def _build_entity_catalog(
*,
project_operating_context: dict[str, Any],
fit_brief: dict[str, Any],
external_recommendation: dict[str, Any],
project_snapshot: dict[str, Any],
) -> list[dict[str, str]]:
catalog: list[dict[str, str]] = []
def _append(name: Any, kind: str, source: str, *, detail: Any = None, status: Any = None) -> None:
normalized = _normalize_text(name, max_len=120)
if not normalized:
return
detail_payload = dict(detail) if isinstance(detail, dict) else {}
detail_text = _normalize_entity_detail(detail_payload.get("text") if detail_payload else detail)
status_text = _normalize_text(status, max_len=40)
object_id = _normalize_object_id(detail_payload.get("id"))
for existing in catalog:
if existing["name"] != normalized:
continue
if detail_text and not existing.get("detail"):
existing["detail"] = detail_text
if status_text and not existing.get("status"):
existing["status"] = status_text
if object_id is not None and existing.get("object_id") is None:
existing["object_id"] = object_id
return
catalog.append(
{
"name": normalized,
"kind": kind,
"source": source,
"object_id": object_id,
"detail": detail_text,
"status": status_text,
}
)
structured_tasks = _iter_structured_snapshot_entities(project_snapshot, key="tasks", kind="task")
structured_signals = _iter_structured_snapshot_entities(project_snapshot, key="signals", kind="signal")
for item in structured_tasks:
_append(
item.get("name"),
"task",
str(item.get("source") or "project_snapshot_task"),
detail={"text": item.get("detail"), "id": item.get("object_id")},
status=item.get("status"),
)
for item in structured_signals:
_append(
item.get("name"),
"signal",
str(item.get("source") or "project_snapshot_signal"),
detail={"text": item.get("detail"), "id": item.get("object_id")},
status=item.get("status"),
)
for item in _normalize_string_list(project_snapshot.get("task_titles"), limit=6):
_append(item, "task", "project_snapshot_task")
for item in _normalize_string_list(project_snapshot.get("signal_titles"), limit=6):
_append(item, "signal", "project_snapshot_signal")
for item in _normalize_string_list(fit_brief.get("signal_source_anchors"), limit=6):
_append(item, "signal", "fit_brief_anchor")
for item in _normalize_string_list(fit_brief.get("current_capabilities"), limit=4):
_append(item, "capability", "fit_brief")
_append(project_snapshot.get("project_name"), "project", "project_snapshot")
_append(fit_brief.get("project_summary"), "project", "fit_brief")
_append(project_operating_context.get("current_repo_scope"), "repo", "project_operating_context")
for item in _normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4):
_append(item, "signal", "external_recommendation")
return catalog
def _entity_detail(entity: dict[str, str] | None) -> str:
if not entity:
return ""
return _normalize_entity_detail(entity.get("detail"))
def _trim_clause(text: str) -> str:
return str(text or "").strip().rstrip("。;;,,")
def _describe_signal_fact(name: str, detail: str, *, suffix: str) -> str:
normalized_detail = _trim_clause(detail)
if not normalized_detail:
return suffix
return f"{name}这条线索当前指向{normalized_detail},{suffix}"
def _entity_text(entity: dict[str, str] | None) -> str:
if not entity:
return ""
return " ".join(
part
for part in (
_normalize_text(entity.get("name"), max_len=120),
_entity_detail(entity),
_normalize_text(entity.get("status"), max_len=40),
_normalize_text(entity.get("source"), max_len=40),
)
if part
).strip()
def _pick_entity_by_object_ref(
entity_catalog: list[dict[str, str]],
*,
object_ref: Any,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
payload = dict(object_ref) if isinstance(object_ref, dict) else {}
if not payload:
return None
used = used_names or set()
target_id = _normalize_object_id(payload.get("id"))
target_title = _normalize_text(payload.get("title"), max_len=120)
target_kind = _normalize_text(payload.get("kind"), max_len=40)
for item in entity_catalog:
item_name = _normalize_text(item.get("name"), max_len=120)
if not item_name or item_name in used:
continue
item_id = _normalize_object_id(item.get("object_id"))
item_kind = _normalize_text(item.get("kind"), max_len=40)
if target_id is not None and item_id == target_id and (not target_kind or target_kind == item_kind):
return dict(item)
if target_title:
return _pick_entity_by_name(entity_catalog, name=target_title, used_names=used)
return None
def _project_snapshot_analysis_refs(project_snapshot: dict[str, Any]) -> dict[str, Any]:
refs = project_snapshot.get("analysis_object_refs")
return dict(refs) if isinstance(refs, dict) else {}
def _iter_entities_by_kind(
entity_catalog: list[dict[str, str]],
*,
kind: str,
used_names: set[str] | None = None,
) -> list[dict[str, str]]:
used = used_names or set()
return [
dict(item)
for item in entity_catalog
if str(item.get("kind") or "").strip() == kind
and str(item.get("name") or "").strip()
and str(item.get("name") or "").strip() not in used
]
def _pick_entity_by_name(
entity_catalog: list[dict[str, str]],
*,
name: Any,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
target = _normalize_text(name, max_len=120)
if not target:
return None
used = used_names or set()
target_key = target.lower()
exact = None
for item in entity_catalog:
item_name = _normalize_text(item.get("name"), max_len=120)
if not item_name or item_name in used:
continue
item_key = item_name.lower()
if item_key == target_key:
exact = dict(item)
break
return exact
def _pick_entity_by_preferred_names(
entity_catalog: list[dict[str, str]],
*,
preferred_names: list[str] | None = None,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
preferred = _normalize_preferred_names(preferred_names)
if not preferred:
return None
for raw_name in preferred:
matched = _pick_entity_by_name(entity_catalog, name=raw_name, used_names=used_names)
if matched is not None:
return matched
return None
def _pick_entity_by_exact_text_binding(
entity_catalog: list[dict[str, str]],
*,
texts: list[Any] | None = None,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
normalized_texts = {
_normalize_text(item, max_len=220).lower()
for item in list(texts or [])
if _normalize_text(item, max_len=220)
}
if not normalized_texts:
return None
used = used_names or set()
for item in entity_catalog:
item_name = _normalize_text(item.get("name"), max_len=220)
if not item_name or item_name in used:
continue
detail = _entity_detail(item)
if item_name.lower() in normalized_texts or detail.lower() in normalized_texts:
return dict(item)
return None
def _entity_matches_object_ref(entity: dict[str, str] | None, object_ref: Any) -> bool:
if not entity:
return False
payload = dict(object_ref) if isinstance(object_ref, dict) else {}
if not payload:
return False
target_id = _normalize_object_id(payload.get("id"))
target_title = _normalize_text(payload.get("title"), max_len=120)
target_kind = _normalize_text(payload.get("kind"), max_len=40)
entity_id = _normalize_object_id(entity.get("object_id"))
entity_name = _normalize_text(entity.get("name"), max_len=120)
entity_kind = _normalize_text(entity.get("kind"), max_len=40)
if target_id is not None and entity_id == target_id and (not target_kind or target_kind == entity_kind):
return True
return bool(target_title and entity_name and target_title == entity_name and (not target_kind or target_kind == entity_kind))
def _task_entity_structural_support(
entity: dict[str, str] | None,
*,
snapshot_refs: dict[str, Any],
anchor_entity: dict[str, str] | None = None,
) -> float:
if not entity or _normalize_text(entity.get("kind"), max_len=40) != "task":
return 0.0
score = 0.0
entity_name = _normalize_text(entity.get("name"), max_len=120)
if not entity_name:
return 0.0
explicit_refs = (
snapshot_refs.get("fit_candidate_task_ref"),
snapshot_refs.get("fit_rejected_task_ref"),
snapshot_refs.get("primary_dependent_task_ref"),
)
if any(_entity_matches_object_ref(entity, ref) for ref in explicit_refs):
score += 8.0
_ = anchor_entity
return score
def _signal_entity_structural_support(
entity: dict[str, str] | None,
*,
snapshot_refs: dict[str, Any],
) -> float:
if not entity or _normalize_text(entity.get("kind"), max_len=40) != "signal":
return 0.0
score = 0.0
explicit_refs = (
snapshot_refs.get("fit_candidate_signal_ref"),
snapshot_refs.get("fit_rejected_signal_ref"),
snapshot_refs.get("primary_anchor_signal_ref"),
)
if any(_entity_matches_object_ref(entity, ref) for ref in explicit_refs):
score += 8.0
return score
def _pick_anchor_entity(
entity_catalog: list[dict[str, str]],
*,
preferred_ref: Any = None,
preferred_names: list[str] | None = None,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
explicit_entity = _pick_entity_by_preferred_names(
_iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names),
preferred_names=preferred_names,
used_names=used_names,
)
if explicit_entity is not None:
return explicit_entity
preferred_entity = _pick_entity_by_object_ref(
entity_catalog,
object_ref=preferred_ref,
used_names=used_names,
)
if preferred_entity is not None:
return preferred_entity
signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names)
if len(signals) == 1:
return dict(signals[0])
return None
def _pick_dependent_task(
entity_catalog: list[dict[str, str]],
*,
anchor_entity: dict[str, str] | None = None,
preferred_ref: Any = None,
preferred_name: Any = None,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
tasks = _iter_entities_by_kind(entity_catalog, kind="task", used_names=used_names)
preferred = _pick_entity_by_object_ref(tasks, object_ref=preferred_ref, used_names=used_names)
if preferred:
return preferred
preferred = _pick_entity_by_name(tasks, name=preferred_name, used_names=used_names)
if preferred:
return preferred
if len(tasks) == 1:
return dict(tasks[0])
if not tasks:
signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names)
preferred_signal = _pick_entity_by_object_ref(signals, object_ref=preferred_ref, used_names=used_names)
if preferred_signal:
return preferred_signal
preferred_signal = _pick_entity_by_name(signals, name=preferred_name, used_names=used_names)
if preferred_signal:
return preferred_signal
if len(signals) == 1:
return dict(signals[0])
return None
_ = anchor_entity
return None
def _pick_fit_contrast_tasks(
entity_catalog: list[dict[str, str]],
*,
snapshot_refs: dict[str, Any] | None = None,
preferred_ref: Any = None,
rejected_ref: Any = None,
used_names: set[str] | None = None,
) -> tuple[dict[str, str] | None, dict[str, str] | None]:
tasks = _iter_entities_by_kind(entity_catalog, kind="task", used_names=used_names)
if not tasks:
signals = _iter_entities_by_kind(entity_catalog, kind="signal", used_names=used_names)
if not signals:
return None, None
preferred_signal = _pick_entity_by_object_ref(signals, object_ref=snapshot_refs.get("fit_candidate_signal_ref") if snapshot_refs else None, used_names=used_names)
rejected_signal = _pick_entity_by_object_ref(
signals,
object_ref=snapshot_refs.get("fit_rejected_signal_ref") if snapshot_refs else None,
used_names={(preferred_signal.get("name") if preferred_signal else "")} | set(used_names or set()),
)
if preferred_signal or rejected_signal:
return preferred_signal, rejected_signal
return None, None
preferred = _pick_entity_by_object_ref(tasks, object_ref=preferred_ref, used_names=used_names)
rejected = _pick_entity_by_object_ref(
tasks,
object_ref=rejected_ref,
used_names={(preferred.get("name") if preferred else "")} | set(used_names or set()),
)
if preferred or rejected:
return preferred, rejected
return None, None
def _pick_best_entity(
entity_catalog: list[dict[str, str]],
*,
role: str,
used_names: set[str] | None = None,
) -> dict[str, str] | None:
used = used_names or set()
if role in {"preferred_fit", "rejected_fit"}:
preferred, rejected = _pick_fit_contrast_tasks(entity_catalog, used_names=used)
return preferred if role == "preferred_fit" else rejected
if role in {"blocked", "anchor"}:
return _pick_anchor_entity(entity_catalog, used_names=used)
if role == "dependent":
return _pick_dependent_task(entity_catalog, used_names=used)
candidates = [
dict(item)
for item in entity_catalog
if str(item.get("name") or "").strip() and str(item.get("name") or "").strip() not in used
]
if len(candidates) == 1:
return candidates[0]
return None
def _build_focus_object(
*,
entity: dict[str, str] | None,
stance: str,
reason: str,
) -> AnalysisFocusObject | None:
if not entity:
return None
name = _normalize_text(entity.get("name"), max_len=120)
if not name:
return None
normalized_reason = _normalize_text(reason, max_len=160)
if not normalized_reason:
return None
return AnalysisFocusObject(
name=name,
kind=str(entity.get("kind") or "unknown").strip() or "unknown",
stance=stance, # type: ignore[arg-type]
reason=normalized_reason,
)
def _build_project_fit_packet(
*,
fit_brief: dict[str, Any],
external_recommendation: dict[str, Any],
recommended_next_step: str,
entity_catalog: list[dict[str, str]],
project_subject: str,
project_snapshot: dict[str, Any],
) -> dict[str, Any]:
retrieval_status = str(external_recommendation.get("retrieval_status") or "").strip().lower()
impact_status = str(external_recommendation.get("impact_status") or "").strip().lower()
used_names: set[str] = set()
focus_objects: list[AnalysisFocusObject] = []
def _append(entity: dict[str, str] | None, stance: str, reason: str) -> None:
focus = _build_focus_object(entity=entity, stance=stance, reason=reason)
if focus is None or focus.name in used_names or len(focus_objects) >= 2:
return
focus_objects.append(focus)
used_names.add(focus.name)
fit_verdict = "uncertain"
current_subject = _normalize_text(project_subject, max_len=120) or None
preferred_layer: str | None = None
rejected_layer: str | None = None
candidate_position: str | None = None
reason_chain: list[str] = []
snapshot_refs = _project_snapshot_analysis_refs(project_snapshot)
project_description = _normalize_text(project_snapshot.get("project_description"), max_len=180)
human_next_step = ""
suppress_generic_fallback_focus = False
if retrieval_status == "evidence_found" and impact_status == "direct_match":
anchor = _pick_anchor_entity(
entity_catalog,
preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"),
preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4),
used_names=used_names,
)
dependent = _pick_dependent_task(
entity_catalog,
anchor_entity=anchor,
preferred_ref=snapshot_refs.get("primary_dependent_task_ref"),
used_names=used_names,
)
_append(dependent, "dependent", "如果这条变化真有影响,最容易先落到这一步。")
_append(anchor, "anchor", "这是当前能直接对上的外部来源。")
fit_verdict = "fit_now"
candidate_position = dependent.get("name") if dependent else anchor.get("name") if anchor else None
preferred_layer = candidate_position
anchor_name = _first_non_empty(anchor.get("name") if anchor else None, "当前正式锚点", max_len=120)
dependent_name = _first_non_empty(dependent.get("name") if dependent else None, anchor_name, max_len=120)
anchor_detail = _trim_clause(_entity_detail(anchor))
dependent_detail = _trim_clause(_entity_detail(dependent))
conclusion = (
f"放到{current_subject}里,这条外部变化已经直接打到{dependent_name}这一步。"
if current_subject
else f"这条外部变化已经直接打到{dependent_name}这一步。"
)
reason_chain = [
(
f"{dependent_name}负责{dependent_detail},会最先接住这条变化。"
if dependent_detail
else f"{dependent_name}是当前最直接会接住这条变化的项目对象。"
),
(
f"对应的正式锚点是{anchor_name},它指向{anchor_detail},已经和外部证据建立了直接映射。"
if anchor_detail
else f"{anchor_name}已经和外部证据建立了直接映射。"
),
]
human_next_step = f"先看这条变化会不会直接改到{dependent_name}这一步的下游动作。"
missing_mapping = _first_non_empty(
external_recommendation.get("external_action"),
f"还缺它具体作用到{dependent_name}这一步哪个字段或触发条件的明确信息。",
max_len=220,
)
elif retrieval_status == "verifier_blocked":
blocked = _pick_anchor_entity(
entity_catalog,
preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"),
preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4),
used_names=used_names,
)
dependent = _pick_dependent_task(
entity_catalog,
anchor_entity=blocked,
preferred_ref=snapshot_refs.get("primary_dependent_task_ref"),
used_names=used_names,
)
_append(blocked, "blocked", "这条线索还没核实清楚,暂时不能直接拿来下结论。")
_append(dependent, "dependent", "如果后面真要用这条变化,还得先把字段和证据对上。")
fit_verdict = "uncertain"
blocked_name = _first_non_empty(blocked.get("name") if blocked else None, "这条线索", max_len=120)
dependent_name = _first_non_empty(dependent.get("name") if dependent else None, "当前项目动作", max_len=120)
blocked_detail = _trim_clause(_entity_detail(blocked))
dependent_detail = _trim_clause(_entity_detail(dependent))
conclusion = (
f"放到{current_subject}里,这条外部变化现在还不能直接挂项目结论。"
if current_subject
else "这条外部变化现在还不能直接挂项目结论。"
)
if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=blocked) <= 0:
reason_chain = [
(
f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。"
if blocked_detail
else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。"
),
"项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。",
]
human_next_step = f"先把{blocked_name}这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。"
else:
reason_chain = [
(
f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。"
if blocked_detail
else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。"
),
(
f"如果后面真要让{dependent_name}用这条变化,还得先把对应字段和证据核实清楚。"
if dependent_detail
else f"{dependent_name}如果要用这条变化,还缺能对上的字段和证据。"
),
]
human_next_step = f"先把{blocked_name}这条线索核实清楚,再判断要不要让{dependent_name}用它。"
missing_mapping = human_next_step
else:
preferred, rejected = _pick_fit_contrast_tasks(
entity_catalog,
snapshot_refs=snapshot_refs,
preferred_ref=snapshot_refs.get("fit_candidate_task_ref") or snapshot_refs.get("fit_candidate_signal_ref"),
rejected_ref=snapshot_refs.get("fit_rejected_task_ref") or snapshot_refs.get("fit_rejected_signal_ref"),
used_names=used_names,
)
_append(preferred, "better_fit", "更适合先在这一步试,看看它到底值不值。")
_append(rejected, "not_fit", "这一步要先稳定跑通,不适合一上来就改。")
fit_verdict = "fit_later" if impact_status == "indirect_only" else "not_fit_yet"
candidate_position = preferred.get("name") if preferred else None
preferred_layer = preferred.get("name") if preferred else None
rejected_layer = rejected.get("name") if rejected else None
preferred_name = _first_non_empty(preferred.get("name") if preferred else None, "结果出来之后的解释层", max_len=120)
rejected_name = _first_non_empty(rejected.get("name") if rejected else None, "当前主链", max_len=120)
preferred_detail = _trim_clause(_entity_detail(preferred))
rejected_detail = _trim_clause(_entity_detail(rejected))
preferred_kind = str(preferred.get("kind") or "").strip() if preferred else ""
rejected_kind = str(rejected.get("kind") or "").strip() if rejected else ""
if preferred is None or rejected is None:
conclusion = (
f"光看{current_subject}现在这些项目信息,我还不想直接说它该放哪一步。"
if current_subject
else "光看现在这些项目信息,我还不想直接说它该放哪一步。"
)
if project_description:
normalized_description = _trim_clause(project_description)
reason_chain = [
f"我现在只看得到这个项目的大致目标:{normalized_description}。",
"但还看不到这条链路里谁来触发、要接哪些外部输入、以及哪个结果算真的跑通。",
]
human_next_step = "至少先把实际流程拆出来:谁发起、系统接什么输入、最后产出什么结果。"
missing_mapping = "还缺能对上的实际流程、关键结果和外部输入。"
else:
reason_chain = [
"现在项目里还没有足够具体的任务或信号源,硬把它挂到某个位置上,结论很容易说偏。"
]
human_next_step = "先把这个项目最关键的业务步骤补清楚,我再判断它更适合放哪里。"
missing_mapping = "至少还缺一个能对上的业务步骤或信号源。"
suppress_generic_fallback_focus = True
elif (
preferred_kind == "task"
and rejected_kind == "task"
and max(
_task_entity_structural_support(preferred, snapshot_refs=snapshot_refs),
_task_entity_structural_support(rejected, snapshot_refs=snapshot_refs),
)
<= 0
):
conclusion = (
f"光看{current_subject}现在这批任务,我还不想硬说它该放哪一步。"
if current_subject
else "光看现在这批任务,我还不想硬说它该放哪一步。"
)
reason_chain = [
f"现在拆出来的主要还是实现层任务,比如{preferred_name}、{rejected_name},还看不出它到底该先影响哪个业务结果。"
]
human_next_step = "先定清它到底想改善哪个业务结果,再看该落到哪一步。"
missing_mapping = "至少还缺一个明确的业务结果或业务动作。"
suppress_generic_fallback_focus = True
elif (
preferred_kind == "signal"
and rejected_kind == "signal"
and max(
_signal_entity_structural_support(preferred, snapshot_refs=snapshot_refs),
_signal_entity_structural_support(rejected, snapshot_refs=snapshot_refs),
)
<= 0
):
conclusion = (
f"光看{current_subject}现在这些信号源占位,我还不想直接说它该放哪一步。"
if current_subject
else "光看现在这些信号源占位,我还不想直接说它该放哪一步。"
)
reason_chain = [
"现在写出来的还是“增加某个信号源”这类占位,不是真正的业务步骤,硬判很容易说偏。"
]
human_next_step = "先定清这个项目最后要给谁看什么结果,再判断哪个信号只是输入、哪个位置更值得先试。"
missing_mapping = "至少还缺一个能落到业务动作上的对象。"
suppress_generic_fallback_focus = True
elif preferred_kind == "signal" or rejected_kind == "signal":
conclusion = (
f"放到{current_subject}里,我更倾向先让它去帮{preferred_name},不太会先拿它改{rejected_name}。"
if current_subject
else f"我更倾向先让它去帮{preferred_name},不太会先拿它改{rejected_name}。"
)
reason_chain = [
(
f"{rejected_name}现在代表的是{rejected_detail},这类前面必须先跑通的条件,不适合直接拿来试新能力。"
if rejected_detail
else f"{rejected_name}更像前面必须先跑通的条件,不适合直接拿来试新能力。"
),
(
f"{preferred_name}现在代表的是{preferred_detail},先从这里试更稳,也更容易看出它有没有价值。"
if preferred_detail
else f"{preferred_name}更像用户最后会看到的结果,先从这里试更稳。"
),
]
human_next_step = f"先定清它到底是给{preferred_name}补说明,还是会直接改到{rejected_name}。"
missing_mapping = f"现在还说不准它会先帮到{preferred_name},还是会直接改到{rejected_name}。"
else:
conclusion = (
f"如果真要放进{current_subject},我会先放在{preferred_name}这一步,不太会直接改{rejected_name}这条主流程。"
if current_subject
else f"如果真要放进去,我会先放在{preferred_name}这一步,不太会直接改{rejected_name}这条主流程。"
)
reason_chain = [
(
f"{rejected_name}现在做的是{rejected_detail},这一步要稳,不能随便把新能力塞进去。"
if rejected_detail
else f"{rejected_name}这一步要稳,不适合一上来就塞新能力。"
),
(
f"{preferred_name}现在做的是{preferred_detail},先在这里试更稳,也更容易看出值不值。"
if preferred_detail
else f"{preferred_name}这一步更适合先试,比直接改{rejected_name}风险小。"
),
]
human_next_step = f"先定清楚它是给{preferred_name}补说明,还是会直接改到{rejected_name},再决定要不要继续做。"
missing_mapping = f"现在还说不准它会先帮到{preferred_name},还是会直接改到{rejected_name}。"
if not focus_objects and not suppress_generic_fallback_focus:
fallback = _pick_best_entity(entity_catalog, role="relevant", used_names=used_names)
_append(fallback, "relevant", "这是当前最直接相关的项目对象。")
packet = ProjectFitAnalysisPacket(
conclusion=conclusion,
focus_objects=focus_objects,
next_step=_normalize_text(human_next_step, max_len=180) or _normalize_text(recommended_next_step, max_len=180) or None,
fit_verdict=fit_verdict, # type: ignore[arg-type]
current_subject=current_subject,
candidate_position=candidate_position,
rejected_position=rejected_layer,
preferred_layer=preferred_layer,
rejected_layer=rejected_layer,
reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)],
missing_mapping=missing_mapping,
)
return packet.model_dump()
def _build_external_impact_packet(
*,
external_recommendation: dict[str, Any],
recommended_next_step: str,
entity_catalog: list[dict[str, str]],
project_subject: str,
project_snapshot: dict[str, Any],
) -> dict[str, Any]:
retrieval_status = str(external_recommendation.get("retrieval_status") or "").strip().lower() or "retrieval_failed"
impact_status = str(external_recommendation.get("impact_status") or "").strip().lower() or "no_direct_impact"
used_names: set[str] = set()
focus_objects: list[AnalysisFocusObject] = []
current_subject = _normalize_text(project_subject, max_len=120) or None
reason_chain: list[str] = []
snapshot_refs = _project_snapshot_analysis_refs(project_snapshot)
human_next_step = ""
def _append(entity: dict[str, str] | None, stance: str, reason: str) -> None:
focus = _build_focus_object(entity=entity, stance=stance, reason=reason)
if focus is None or focus.name in used_names or len(focus_objects) >= 2:
return
focus_objects.append(focus)
used_names.add(focus.name)
if retrieval_status == "verifier_blocked":
blocked = _pick_anchor_entity(
entity_catalog,
preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"),
preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4),
used_names=used_names,
)
dependent = _pick_dependent_task(
entity_catalog,
anchor_entity=blocked,
preferred_ref=snapshot_refs.get("primary_dependent_task_ref"),
used_names=used_names,
)
_append(blocked, "blocked", "这条线索还没核实清楚,不能直接拿来下结论。")
_append(dependent, "dependent", "如果后面真要用这条变化,这一步会最先受影响。")
blocked_name = _first_non_empty(blocked.get("name") if blocked else None, "这条线索", max_len=120)
dependent_name = _first_non_empty(dependent.get("name") if dependent else None, "当前项目动作", max_len=120)
blocked_detail = _trim_clause(_entity_detail(blocked))
dependent_detail = _trim_clause(_entity_detail(dependent))
conclusion = (
f"这条外部变化现在还不能直接挂到{current_subject}的项目结论上。"
if current_subject
else "这条外部变化现在还不能直接挂到当前项目结论上。"
)
if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=blocked) <= 0:
reason_chain = [
(
f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。"
if blocked_detail
else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。"
),
"项目里现在更多还是实现层任务,我不想硬说它会直接改到哪一步,先把这条线索核实清楚更稳。",
]
human_next_step = f"先把{blocked_name}这条线索核实清楚,再确认它到底会影响这个项目里的哪个结果。"
else:
reason_chain = [
(
f"现在卡在{blocked_name}这条线索上:它指向{blocked_detail},但还没通过校验。"
if blocked_detail
else f"{blocked_name}这条线索还没通过校验,当前不能直接挂到项目结论上。"
),
(
f"如果后面真要让{dependent_name}用这条变化,还得先把对应字段和证据核实清楚。"
if dependent_detail
else f"{dependent_name}如果要用这条变化,还缺能对上的字段和证据。"
),
]
human_next_step = f"先把{blocked_name}这条线索核实清楚,再判断要不要让{dependent_name}用它。"
elif retrieval_status == "evidence_found" and impact_status == "direct_match":
anchor = _pick_anchor_entity(
entity_catalog,
preferred_ref=snapshot_refs.get("primary_anchor_signal_ref"),
preferred_names=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4),
used_names=used_names,
)
dependent = _pick_dependent_task(
entity_catalog,
anchor_entity=anchor,
preferred_ref=snapshot_refs.get("primary_dependent_task_ref"),
used_names=used_names,
)
_append(dependent, "dependent", "如果这条变化真有影响,最容易先落到这一步。")
_append(anchor, "anchor", "这是当前能直接对上的外部来源。")
anchor_name = _first_non_empty(anchor.get("name") if anchor else None, "当前正式锚点", max_len=120)
dependent_name = _first_non_empty(dependent.get("name") if dependent else None, anchor_name, max_len=120)
anchor_detail = _trim_clause(_entity_detail(anchor))
dependent_detail = _trim_clause(_entity_detail(dependent))
if _task_entity_structural_support(dependent, snapshot_refs=snapshot_refs, anchor_entity=anchor) <= 0:
conclusion = (
f"这条外部变化已经和{current_subject}对上了,但现在还不适合硬挂到某个实现任务上。"
if current_subject
else "这条外部变化已经对上了,但现在还不适合硬挂到某个实现任务上。"
)
reason_chain = [
(
f"能对上的外部来源是{anchor_name},它指向{anchor_detail},所以这次不是泛泛相关。"
if anchor_detail
else f"能对上的外部来源是{anchor_name},所以这次不是泛泛相关。"
),
"项目里现在更多还是实现层任务,先别急着说它会直接改到哪一步,先看它到底影响哪个业务结果。",
]
human_next_step = "先确认这条变化会影响哪个业务结果,再决定要不要拆到具体任务。"
else:
conclusion = (
f"这条外部变化已经直接打到{current_subject}里的{dependent_name}这一步。"
if current_subject
else f"这条外部变化已经直接打到{dependent_name}这一步。"
)
reason_chain = [
(
f"{dependent_name}负责{dependent_detail},会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。"
if dependent_detail
else f"{dependent_name}会最先接住这条变化,可以当成后续收口和计划讨论的正式输入。"
),
(
f"能对上的外部来源是{anchor_name},它指向{anchor_detail},所以这次不是泛泛相关。"
if anchor_detail
else f"能对上的外部来源是{anchor_name},所以这次不是泛泛相关。"
),
]
human_next_step = f"下一步就看这条变化会不会直接改到{dependent_name}后面的动作或规则。"
elif retrieval_status == "evidence_found" and impact_status == "indirect_only":
preferred, rejected = _pick_fit_contrast_tasks(
entity_catalog,
snapshot_refs=snapshot_refs,
preferred_ref=snapshot_refs.get("fit_candidate_task_ref") or snapshot_refs.get("fit_candidate_signal_ref"),
rejected_ref=snapshot_refs.get("fit_rejected_task_ref") or snapshot_refs.get("fit_rejected_signal_ref"),
used_names=used_names,
)
_append(preferred, "better_fit", "如果继续看,这一步更适合先试。")
_append(rejected, "not_fit", "这一步现在还得按稳定流程跑。")
preferred_name = _first_non_empty(preferred.get("name") if preferred else None, "解释层", max_len=120)
rejected_name = _first_non_empty(rejected.get("name") if rejected else None, "当前主链", max_len=120)
preferred_detail = _trim_clause(_entity_detail(preferred))
rejected_detail = _trim_clause(_entity_detail(rejected))
conclusion = (
f"这条外部变化现在只形成背景相关,还没真正落到{current_subject}的正式对象上。"
if current_subject
else "这条外部变化现在只形成背景相关,还没真正落到当前项目的正式对象上。"
)
if (
max(
_task_entity_structural_support(preferred, snapshot_refs=snapshot_refs),
_task_entity_structural_support(rejected, snapshot_refs=snapshot_refs),
)
<= 0
):
reason_chain = [
f"项目里现在更多还是实现层任务,比如{preferred_name}、{rejected_name},还看不出这条外部变化会先影响哪个业务结果。"
]
human_next_step = "先看这条外部变化到底影响哪个业务结果,再决定要不要拆到具体任务。"
else:
reason_chain = [
(
f"{rejected_name}现在做的是{rejected_detail},这一步还得按现在的稳定流程跑,不能直接把这条外部变化挂进去。"
if rejected_detail
else f"{rejected_name}这一步还得按现在的稳定流程跑,不能直接把这条外部变化挂进去。"
),
(
f"如果继续看,我会先从{preferred_name}这一步试,因为{preferred_detail or f'这里比直接改{rejected_name}更容易先试出价值'}。"
if preferred_detail
else f"如果继续看,我会先从{preferred_name}这一步试,不会先去动{rejected_name}。"
),
]
human_next_step = f"如果继续看,先看{preferred_name}能不能先用上,再决定要不要动{rejected_name}。"
elif retrieval_status == "no_relevant_results":
_append(_pick_best_entity(entity_catalog, role="relevant", used_names=used_names), "relevant", "当前只找到最接近的项目对象,但还没有外部证据真正落上来。")
conclusion = (
f"目前还没拿到与{current_subject}直接相关的外部证据。"
if current_subject
else "目前还没拿到与当前项目直接相关的外部证据。"
)
reason_chain = ["当前只找到最接近的项目对象,但还没有外部证据真正落上来。"]
else:
_append(_pick_best_entity(entity_catalog, role="relevant", used_names=used_names), "relevant", "当前只保留最接近的项目对象,等待后续补证。")
conclusion = "这次外部检索没有形成可直接消费的项目结论。"
reason_chain = ["当前只保留最接近的项目对象,等待后续补证。"]
packet = ExternalImpactAnalysisPacket(
conclusion=conclusion,
focus_objects=focus_objects,
next_step=_normalize_text(human_next_step, max_len=180) or _normalize_text(recommended_next_step, max_len=180) or None,
retrieval_status=retrieval_status, # type: ignore[arg-type]
impact_status=impact_status if impact_status in {"direct_match", "indirect_only", "no_direct_impact"} else "no_direct_impact", # type: ignore[arg-type]
current_subject=current_subject,
reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)],
anchor_objects=_normalize_string_list(external_recommendation.get("affected_signal_sources"), limit=4),
blocked_reason_or_gap=_first_non_empty(
human_next_step,
recommended_next_step,
external_recommendation.get("external_action"),
external_recommendation.get("external_impact"),
external_recommendation.get("external_summary"),
"当前还缺能把外部变化稳定映射到项目对象上的证据。",
max_len=220,
),
)
return packet.model_dump()
def _build_internal_explain_packet(
*,
answer: str,
query_type: str,
evidence_pack: dict[str, Any],
internal_context_packet: Any,
entity_catalog: list[dict[str, str]],
) -> dict[str, Any]:
operation_contract = _internal_packet_section(internal_context_packet, "operation_contract")
query_plan = _internal_packet_section(internal_context_packet, "query_plan")
target_resolution = _internal_packet_section(internal_context_packet, "target_resolution")
answer_contract = _first_non_empty(
query_plan.get("answer_contract"),
operation_contract.get("answer_contract"),
query_type,
max_len=80,
)
resolved_target = _first_non_empty(
target_resolution.get("primary_target_label"),
target_resolution.get("target_label"),
target_resolution.get("matched_target_label"),
target_resolution.get("target_name"),
max_len=120,
)
if not resolved_target:
matched_entity = _pick_entity_by_exact_text_binding(
entity_catalog,
texts=[answer, _safe_dict(evidence_pack.get("summary")).get("conclusion")],
)
resolved_target = _normalize_text((matched_entity or {}).get("name"), max_len=120)
uncertainties = _normalize_string_list(_safe_dict(evidence_pack.get("summary")).get("uncertainties"), limit=2, max_len=120)
conclusion = _first_non_empty(
_safe_dict(evidence_pack.get("summary")).get("conclusion"),
answer,
"当前内部对象已经拿到可解释结果。",
max_len=220,
)
current_subject = resolved_target or None
reason_chain: list[str] = []
focus_objects: list[AnalysisFocusObject] = []
if resolved_target:
focus_objects.append(
AnalysisFocusObject(
name=resolved_target,
kind="internal_target",
stance="relevant",
reason="这次回答说的就是这个对象。",
)
)
if resolved_target and answer_contract in {"status_summary", "status"}:
reason_chain.append(f"这次就按{resolved_target}的当前情况回答,先不展开方案。")
elif resolved_target:
reason_chain.append(f"这次先把{resolved_target}现在是什么情况说清楚。")
elif answer_contract:
reason_chain.append("这次先直接回答当前情况,不往方案上展开。")
packet = InternalExplainAnalysisPacket(
conclusion=conclusion,
focus_objects=focus_objects,
answer_contract=answer_contract or None,
current_subject=current_subject,
reason_chain=[_normalize_text(item, max_len=180) for item in reason_chain if _normalize_text(item, max_len=180)],
resolved_target=resolved_target or None,
evidence_gap_or_uncertainty=uncertainties[0] if uncertainties else None,
)
return packet.model_dump()
def _build_followup_explain_packet(
*,
answer: str,
evidence_pack: dict[str, Any],
clarification_code: str,
anchor_kind: str,
meta_followup_kind: str,
) -> dict[str, Any]:
summary = _safe_dict(evidence_pack.get("summary"))
uncertainties = _normalize_string_list(summary.get("uncertainties"), limit=2, max_len=120)
packet = FollowupExplainAnalysisPacket(
conclusion=_first_non_empty(
summary.get("conclusion"),
answer,
"当前回复依赖上一轮上下文。",
max_len=220,
),
focus_objects=[],
anchor_kind=anchor_kind or None,
continuation_kind=meta_followup_kind or None,
clarification_state=clarification_code or None,
missing_mapping=uncertainties[0] if uncertainties else None,
)
return packet.model_dump()
def build_analysis_packet(
*,
route_decision: Any = None,
project_operating_context: Any = None,
execution_recipe: Any = None,
fit_brief: Any = None,
external_recommendation: Any = None,
recommended_next_step: Any = None,
answer: Any = None,
evidence_pack: Any = None,
query_type: Any = None,
clarification_code: Any = None,
anchor_kind: Any = None,
meta_followup_kind: Any = None,
internal_context_packet: Any = None,
project_snapshot: Any = None,
) -> dict[str, Any]:
if not _env_bool(_ENV_ENABLE_ANALYSIS_PACKET_FAMILY, True):
return {}
normalized_route = _safe_dict(route_decision)
normalized_project_context = _safe_dict(project_operating_context)
normalized_recipe = _safe_dict(execution_recipe)
normalized_fit_brief = _safe_dict(fit_brief)
normalized_external = _safe_dict(external_recommendation)
normalized_evidence_pack = _safe_dict(evidence_pack)
normalized_project_snapshot = _safe_dict(project_snapshot)
recommended_next = _normalize_text(recommended_next_step, max_len=180)
normalized_query_type = str(query_type or "").strip().lower()
normalized_clarification_code = str(clarification_code or "").strip().lower()
normalized_anchor_kind = str(anchor_kind or "").strip().lower()
normalized_followup_kind = str(meta_followup_kind or "").strip().lower()
project_subject = _project_subject(
project_snapshot=normalized_project_snapshot,
fit_brief=normalized_fit_brief,
project_operating_context=normalized_project_context,
)
if normalized_clarification_code or normalized_anchor_kind or normalized_followup_kind:
return _build_followup_explain_packet(
answer=_normalize_text(answer, max_len=220),
evidence_pack=normalized_evidence_pack,
clarification_code=normalized_clarification_code,
anchor_kind=normalized_anchor_kind,
meta_followup_kind=normalized_followup_kind,
)
effective_source_policy = _normalize_route_mode(
normalized_route.get("effective_source_policy") or normalized_route.get("route_mode")
)
entity_catalog = _build_entity_catalog(
project_operating_context=normalized_project_context,
fit_brief=normalized_fit_brief,
external_recommendation=normalized_external,
project_snapshot=normalized_project_snapshot,
)
if effective_source_policy == "internal":
return _build_internal_explain_packet(
answer=_normalize_text(answer, max_len=220),
query_type=normalized_query_type,
evidence_pack=normalized_evidence_pack,
internal_context_packet=internal_context_packet,
entity_catalog=entity_catalog,
)
if effective_source_policy == "external" and bool(normalized_recipe.get("fit_brief_required")):
return _build_project_fit_packet(
fit_brief=normalized_fit_brief,
external_recommendation=normalized_external,
recommended_next_step=recommended_next,
entity_catalog=entity_catalog,
project_subject=project_subject,
project_snapshot=normalized_project_snapshot,
)
if effective_source_policy == "external" and normalized_external:
return _build_external_impact_packet(
external_recommendation=normalized_external,
recommended_next_step=recommended_next,
entity_catalog=entity_catalog,
project_subject=project_subject,
project_snapshot=normalized_project_snapshot,
)
return {}
__all__ = ["build_analysis_packet", "normalize_analysis_packet_payload"]