from __future__ import annotations from .shared import * def _sync_pmflow_rag_async( *, tenant_id: int, record_type: str, record_id: int, project_id: int, title: str, content: dict[str, Any] | str | None, ) -> None: try: if isinstance(content, str): payload = content else: payload = json.dumps(content or {}, ensure_ascii=False) asyncio.create_task( AdaptiveRAGService.upsert_pmflow_record_async( tenant_id=tenant_id, record_type=record_type, record_id=record_id, project_id=project_id, title=title, content=payload, ) ) except Exception: return def _parse_project_id(project_id: str) -> int: try: return int(project_id) except ValueError as exc: raise HTTPException(status_code=400, detail="Invalid project_id") from exc def _parse_node_id(node_id: str, label: str) -> int: try: return int(node_id) except ValueError as exc: raise HTTPException(status_code=400, detail=f"Invalid {label}") from exc def _as_dict(value: Any) -> dict: if isinstance(value, dict): return value if isinstance(value, str): import json try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {"text": value} except (TypeError, ValueError): return {"text": value} return {} def _signal_ai_analysis(signal: Signal) -> dict: payload = _as_dict(signal.strategic_analysis) ai_payload = payload.get("ai_analysis") return ai_payload if isinstance(ai_payload, dict) else {} def _signal_project_scope(signal: Signal) -> Optional[str]: scope = str(_signal_ai_analysis(signal).get("requirement_project_scope") or "").strip().lower() if scope in {"new_project", "existing_project", "pending_classification", "non_requirement"}: return scope return None def _signal_display_type(signal: Signal) -> str: scope = _signal_project_scope(signal) if scope: return scope return "pending_classification" def _signal_source_context(signal: Signal) -> dict: payload = _as_dict(signal.strategic_analysis) source_context = payload.get("source_context") return source_context if isinstance(source_context, dict) else {} def _normalize_planner_status(value: Any) -> str: raw = str(value or "").strip().lower() if raw in {"pending", "completed", "failed"}: return raw return "pending" def _safe_int(value: Any) -> Optional[int]: try: return int(str(value).strip()) except (TypeError, ValueError, AttributeError): return None def _signal_attachment_storage_root() -> Path: root = Path(__file__).resolve().parents[4] / "storage" / "signal_attachments" root.mkdir(parents=True, exist_ok=True) return root def _build_signal_attachment_download_url(project_id: int, signal_id: int, attachment_id: str) -> str: return f"/projects/{project_id}/signals/{signal_id}/attachments/{attachment_id}/download" def _resolve_local_attachment_path(file_key: str) -> Optional[Path]: raw = str(file_key or "").strip() if not raw.startswith("local://"): return None relative = raw.removeprefix("local://").strip().lstrip("/") if not relative: return None storage_root = _signal_attachment_storage_root().resolve() candidate = (storage_root / relative).resolve() try: candidate.relative_to(storage_root) except ValueError: return None return candidate def _error_json(status_code: int, message: str) -> JSONResponse: return JSONResponse(status_code=status_code, content=error_response(status_code, message)) def _normalize_attachment_type(value: Any) -> str: raw = str(value or "").strip().lower() alias = { "img": "image", "picture": "image", "attachment": "file", "doc": "document", "docs": "document", } mapped = alias.get(raw, raw) return mapped if mapped in _ALLOWED_ATTACHMENT_TYPES else "file" def _normalize_signal_attachment(item: Any, *, index: int) -> Optional[dict[str, Any]]: if not isinstance(item, dict): return None attachment_type = _normalize_attachment_type(item.get("attachment_type") or item.get("type")) attachment_id = ( str(item.get("attachment_id") or item.get("id") or item.get("file_key") or item.get("image_key") or item.get("media_id") or "") .strip() ) if not attachment_id: attachment_id = f"attachment-{index + 1}" file_name = ( str(item.get("file_name") or item.get("name") or item.get("title") or "").strip() or f"{attachment_type}-{attachment_id}" ) file_size = _safe_int(item.get("file_size") or item.get("size")) mime_type = str(item.get("mime_type") or item.get("mime") or "").strip() or None occurred_at = str(item.get("occurred_at") or "").strip() or None download_url = str(item.get("download_url") or item.get("url") or "").strip() or None file_key = str(item.get("file_key") or "").strip() or None image_key = str(item.get("image_key") or "").strip() or None media_id = str(item.get("media_id") or "").strip() or None source = str(item.get("source") or "").strip() or None if not any([file_key, image_key, media_id, download_url]): return None return { "id": attachment_id, "attachment_id": attachment_id, "attachment_type": attachment_type, "file_name": file_name, "mime_type": mime_type, "file_size": file_size, "file_key": file_key, "image_key": image_key, "media_id": media_id, "source": source, "occurred_at": occurred_at, "download_url": download_url, } def _extract_source_attachments(source_context: dict[str, Any]) -> list[dict[str, Any]]: raw_items = source_context.get("attachments") if not isinstance(raw_items, list): return [] items: list[dict[str, Any]] = [] seen_ids: set[str] = set() for idx, raw in enumerate(raw_items): normalized = _normalize_signal_attachment(raw, index=idx) if normalized is None: continue aid = str(normalized.get("attachment_id") or "").strip() if not aid or aid in seen_ids: continue seen_ids.add(aid) items.append(normalized) return items def _extract_signal_attachments(signal: Signal) -> list[dict[str, Any]]: source_context = _signal_source_context(signal) return _extract_source_attachments(source_context) def _normalize_signal_status_code( *, linked_state: Optional[str], signal_status: Optional[str], processed: bool, ) -> str: if linked_state in {_SIGNAL_STATUS_CODE_LINKED, _SIGNAL_STATUS_CODE_CLOSED}: return linked_state raw = str(signal_status or "").strip().lower() if "关联" in raw or raw == _SIGNAL_STATUS_CODE_LINKED: return _SIGNAL_STATUS_CODE_LINKED if "关闭" in raw or "归档" in raw or raw in {"closed", "archived"}: return _SIGNAL_STATUS_CODE_CLOSED if processed: return _SIGNAL_STATUS_CODE_CLOSED return _SIGNAL_STATUS_CODE_PENDING def _signal_status_label_from_code(status_code: str, fallback_status: Optional[str] = None) -> str: fallback = str(fallback_status or "").strip() normalized_code = str(status_code or "").strip().lower() if ( normalized_code == _SIGNAL_STATUS_CODE_PENDING and fallback and all(token not in fallback for token in ["关联", "关闭", "归档"]) ): return fallback label = _SIGNAL_STATUS_LABEL_BY_CODE.get(normalized_code) if label: return label return fallback or _SIGNAL_STATUS_LABEL_BY_CODE[_SIGNAL_STATUS_CODE_PENDING]