PMFlow/backend/app/services/sstc/signal_core.py

219 lines
7.7 KiB
Python

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]