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) if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: return cls._coerce_intent_structured_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