PMFlow/backend/app/services/llm_service/mixin_structured_candidates.py

325 lines
14 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.

from __future__ import annotations
import re
from .shared import *
class LLMStructuredCandidatesMixin:
@staticmethod
def _normalize_string_list(value: Any) -> list[str]:
if value is None:
return []
if not isinstance(value, (list, tuple, set)):
value = [value]
normalized: list[str] = []
seen: set[str] = set()
for raw in value:
text = str(raw or "").strip()
if not text:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
normalized.append(text[:120])
return normalized
@staticmethod
def _normalize_int_list(value: Any) -> list[int]:
if value is None:
return []
if not isinstance(value, (list, tuple, set)):
value = [value]
normalized: list[int] = []
seen: set[int] = set()
for raw in value:
if isinstance(raw, bool):
continue
try:
number = int(str(raw).strip())
except (TypeError, ValueError):
continue
if number <= 0 or number in seen:
continue
seen.add(number)
normalized.append(number)
return normalized
@classmethod
def _coerce_task_breakdown_item(cls, payload: dict[str, Any]) -> dict[str, Any]:
normalized = dict(payload)
title = str(normalized.get("title") or normalized.get("task_name") or "").strip()
description = normalized.get("description")
task_type = str(normalized.get("type") or normalized.get("task_type") or "backend").strip().lower() or "backend"
task_key = str(normalized.get("task_key") or normalized.get("key") or "").strip()
if not task_key and title:
task_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or title
normalized["task_key"] = task_key
normalized["title"] = title
if description is not None:
normalized["description"] = str(description or "").strip() or None
normalized["type"] = task_type
estimated_hours = cls._coerce_number(normalized.get("estimated_hours"))
normalized["estimated_hours"] = float(estimated_hours or 0.0)
required_skill_names = normalized.get("required_skill_names")
normalized["required_skill_names"] = cls._normalize_string_list(required_skill_names)
normalized["required_skill_tag_ids"] = cls._normalize_int_list(normalized.get("required_skill_tag_ids"))
normalized["depends_on_task_keys"] = cls._normalize_string_list(normalized.get("depends_on_task_keys"))
risk_level = str(normalized.get("risk_level") or "medium").strip().lower() or "medium"
normalized["risk_level"] = risk_level
risk_notes = normalized.get("risk_notes")
normalized["risk_notes"] = str(risk_notes or "").strip() or None
confidence = cls._coerce_number(normalized.get("confidence"))
if confidence is None:
confidence = 0.0
if confidence > 1 and confidence <= 100:
confidence = confidence / 100.0
normalized["confidence"] = max(0.0, min(float(confidence), 1.0))
normalized["evidence_refs"] = cls._normalize_string_list(normalized.get("evidence_refs"))
normalized["priority"] = str(normalized.get("priority") or "medium").strip().lower() or "medium"
return normalized
@classmethod
def _coerce_task_breakdown_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
normalized = dict(payload)
tasks = normalized.get("tasks")
if isinstance(tasks, list):
normalized["tasks"] = [
cls._coerce_task_breakdown_item(item)
for item in tasks
if isinstance(item, dict)
]
normalized["detected_skills"] = cls._normalize_string_list(normalized.get("detected_skills"))
source_context = normalized.get("source_context")
if source_context is not None and not isinstance(source_context, dict):
normalized["source_context"] = source_context if isinstance(source_context, dict) else None
return normalized
@staticmethod
def _validate_task_breakdown_item(payload: dict[str, Any]) -> bool:
required_text_fields = ("task_key", "title", "type", "risk_level")
for key in required_text_fields:
value = str(payload.get(key) or "").strip()
if not value:
return False
if not isinstance(payload.get("estimated_hours"), (int, float)) or isinstance(payload.get("estimated_hours"), bool):
return False
if not isinstance(payload.get("confidence"), (int, float)) or isinstance(payload.get("confidence"), bool):
return False
for key in ("required_skill_names", "required_skill_tag_ids", "depends_on_task_keys", "evidence_refs"):
if not isinstance(payload.get(key), list):
return False
priority = payload.get("priority")
if priority is not None and not isinstance(priority, str):
return False
risk_notes = payload.get("risk_notes")
if risk_notes is not None and not isinstance(risk_notes, str):
return False
description = payload.get("description")
if description is not None and not isinstance(description, str):
return False
return True
def _iter_structured_candidates(
self,
*,
message: Any,
choice: Any,
response: Any,
include_reasoning: bool = True,
) -> list[tuple[str, Any]]:
candidates: list[tuple[str, Any]] = []
if isinstance(message, dict):
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
first = tool_calls[0] if isinstance(tool_calls[0], dict) else {}
fn = first.get("function") if isinstance(first, dict) else {}
candidates.append(("tool_calls.function.arguments", fn.get("arguments") if isinstance(fn, dict) else None))
candidates.append(("message.content", message.get("content")))
if isinstance(choice, dict):
candidates.append(("choice.text", choice.get("text")))
if isinstance(response, dict):
candidates.append(("response.output_text", response.get("output_text")))
if include_reasoning:
if isinstance(message, dict):
candidates.append(("message.reasoning_content", message.get("reasoning_content")))
candidates.append(("message.reasoning_content", message.get("reasoning")))
if isinstance(choice, dict):
candidates.append(("choice.reasoning_content", choice.get("reasoning_content")))
candidates.append(("choice.reasoning_content", choice.get("reasoning")))
if isinstance(response, dict):
candidates.append(("response.reasoning_content", response.get("reasoning_content")))
candidates.append(("response.reasoning_content", response.get("reasoning")))
return candidates
@classmethod
def _unwrap_structured_payload_once(cls, payload: Any) -> Any:
if not isinstance(payload, dict):
return payload
for key in ("json", "data", "result", "arguments"):
if key not in payload:
continue
inner = payload.get(key)
if isinstance(inner, str):
candidate = cls._extract_json_candidate(cls._strip_code_fence(inner))
if not candidate:
continue
try:
return json.loads(candidate)
except Exception:
return inner
if isinstance(inner, (dict, list)):
return inner
return payload
@staticmethod
def _matches_expected_type(value: Any, expected: str) -> bool:
if expected == "bool":
return isinstance(value, bool)
if expected == "str":
return isinstance(value, str)
if expected == "list":
return isinstance(value, list)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "number_or_none":
return value is None or (isinstance(value, (int, float)) and not isinstance(value, bool))
if expected == "str_or_none":
return value is None or isinstance(value, str)
if expected == "dict_or_none":
return value is None or isinstance(value, dict)
return True
@staticmethod
def _coerce_number(value: Any) -> Optional[float]:
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value or "").strip()
if not text:
return None
try:
return float(text)
except Exception:
return None
@staticmethod
def _coerce_int(value: Any) -> Optional[int]:
number = LLMStructuredCandidatesMixin._coerce_number(value)
if number is None:
return None
try:
return int(number)
except Exception:
return None
@staticmethod
def _coerce_bool(value: Any) -> Optional[bool]:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)) and not isinstance(value, bool):
return bool(int(value))
text = str(value or "").strip().lower()
if not text:
return None
if text in BOOL_TRUE_VALUES:
return True
if text in {"0", "false", "no", "off", "n"}:
return False
return None
@classmethod
def _coerce_intent_structured_payload(cls, payload: dict[str, Any]) -> dict[str, Any]:
normalized = dict(payload)
confidence = cls._coerce_number(normalized.get("confidence"))
if confidence is None:
for alias in ("score", "probability", "confidence_score"):
confidence = cls._coerce_number(normalized.get(alias))
if confidence is not None:
break
if confidence is not None:
normalized["confidence"] = confidence
reasoning_value = normalized.get("reasoning")
if isinstance(reasoning_value, list):
text_items = [str(item).strip() for item in reasoning_value if str(item).strip()]
normalized["reasoning"] = ";".join(text_items)
elif isinstance(reasoning_value, dict):
normalized["reasoning"] = json.dumps(reasoning_value, ensure_ascii=False)
else:
reasoning_text = str(reasoning_value or "").strip()
if reasoning_text:
normalized["reasoning"] = reasoning_text
else:
for alias in ("reason", "rationale", "analysis", "explanation"):
alias_text = str(normalized.get(alias) or "").strip()
if alias_text:
normalized["reasoning"] = alias_text
break
if "requires_confirmation" in normalized:
bool_value = cls._coerce_bool(normalized.get("requires_confirmation"))
if bool_value is not None:
normalized["requires_confirmation"] = bool_value
for field in ("project_id", "requested_count"):
if field in normalized:
number = cls._coerce_int(normalized.get(field))
normalized[field] = number if number is not None else None
route_hint = str(
normalized.get("workflow_route") or normalized.get("route") or ""
).strip().lower()
if route_hint and "." in route_hint:
family, action = route_hint.split(".", 1)
if not str(normalized.get("family") or "").strip():
normalized["family"] = family
if not str(normalized.get("action") or "").strip():
normalized["action"] = action
return normalized
@classmethod
def _coerce_structured_payload(cls, *, function_name: str, payload: Any) -> Any:
fn = str(function_name or "").strip().lower()
if not isinstance(payload, dict):
return payload
if fn in {"generate_task_breakdown", "sstc_ai_breakdown"}:
return cls._coerce_task_breakdown_payload(payload)
return payload
def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]:
fn = str(function_name or "").strip().lower()
schema = STRUCTURED_OBJECT_SCHEMAS.get(fn)
payload = self._coerce_structured_payload(function_name=fn, payload=payload)
if schema is None:
return None if isinstance(payload, (dict, list)) else "structured_json_invalid"
if not isinstance(payload, dict):
return "structured_schema_invalid"
required_keys = schema.get("required_keys") or ()
for key in required_keys:
if key not in payload:
return "structured_schema_invalid"
type_checks = schema.get("type_checks") or {}
for key, expected in type_checks.items():
if not self._matches_expected_type(payload.get(key), str(expected)):
return "structured_schema_invalid"
optional_type_checks = schema.get("optional_type_checks") or {}
for key, expected_values in optional_type_checks.items():
if key not in payload:
continue
if not any(self._matches_expected_type(payload.get(key), str(expected)) for expected in expected_values):
return "structured_schema_invalid"
if fn in {"generate_task_breakdown", "sstc_ai_breakdown"}:
tasks = payload.get("tasks")
if not isinstance(tasks, list) or not tasks:
return "structured_schema_invalid"
if not all(isinstance(item, dict) and self._validate_task_breakdown_item(item) for item in tasks):
return "structured_schema_invalid"
return None