1236 lines
54 KiB
Python
1236 lines
54 KiB
Python
"""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"]
|