219 lines
7.7 KiB
Python
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]
|
|
|