PMFlow/backend/app/services/analysis_project_snapshot_l...

131 lines
4.2 KiB
Python

"""Shared loader for richer analysis project snapshots."""
from __future__ import annotations
from typing import Any
from sqlalchemy import and_, select
from app.models.project import Project
from app.models.signal import Signal
from app.models.task import Task
def _normalize_text(value: Any, *, max_len: int = 240) -> str:
text = " ".join(str(value or "").split()).strip()
if not text:
return ""
return text[:max_len]
def _object_ref(*, kind: str, row: dict[str, Any] | None) -> dict[str, Any] | None:
payload = dict(row or {})
title = _normalize_text(payload.get("title"), max_len=120)
if not title:
return None
ref: dict[str, Any] = {
"kind": kind,
"title": title,
}
try:
object_id = int(payload.get("id")) if payload.get("id") is not None else None
except Exception:
object_id = None
if object_id is not None:
ref["id"] = object_id
return ref
def _build_analysis_object_refs(
*,
tasks: list[dict[str, str]],
signals: list[dict[str, str]],
) -> dict[str, Any]:
_ = (tasks, signals)
# Fail closed: without explicit structural task-signal relations in the data model,
# do not synthesize business object refs from task/signal wording.
return {}
async def load_analysis_project_snapshot(
*,
db: Any,
tenant_id: int | None,
project_id: int | None,
task_limit: int = 4,
signal_limit: int = 6,
) -> dict[str, Any]:
if project_id is None or db is None or not hasattr(db, "execute"):
return {}
project_filters = [Project.id == int(project_id)]
task_filters = [Task.project_id == int(project_id)]
signal_filters = [Signal.matched_project_id == int(project_id)]
if tenant_id is not None:
project_filters.append(Project.tenant_id == int(tenant_id))
task_filters.append(Task.tenant_id == int(tenant_id))
signal_filters.append(Signal.tenant_id == int(tenant_id))
project_row = (
await db.execute(
select(Project).where(and_(*project_filters)).limit(1)
)
).scalar_one_or_none()
if project_row is None:
return {}
task_rows = (
await db.execute(
select(Task)
.where(and_(*task_filters))
.order_by(Task.id.desc())
.limit(int(task_limit))
)
).scalars().all()
signal_rows = (
await db.execute(
select(Signal)
.where(and_(*signal_filters))
.order_by(Signal.id.desc())
.limit(int(signal_limit))
)
).scalars().all()
tasks = [
{
"id": getattr(row, "id", None),
"title": _normalize_text(getattr(row, "title", ""), max_len=120),
"description": _normalize_text(getattr(row, "description", ""), max_len=220),
"status": _normalize_text(getattr(row, "status", ""), max_len=40),
}
for row in task_rows
if _normalize_text(getattr(row, "title", ""), max_len=120)
]
signals = [
{
"id": getattr(row, "id", None),
"title": _normalize_text(getattr(row, "title", ""), max_len=120),
"summary": _normalize_text(getattr(row, "summary", ""), max_len=220),
"reasoning": _normalize_text(getattr(row, "reasoning", ""), max_len=220),
"status": _normalize_text(getattr(row, "status", ""), max_len=40),
"source": _normalize_text(getattr(row, "source", ""), max_len=120),
}
for row in signal_rows
if _normalize_text(getattr(row, "title", ""), max_len=120)
]
analysis_object_refs = _build_analysis_object_refs(tasks=tasks, signals=signals)
return {
"project_name": _normalize_text(
getattr(project_row, "title", None) or getattr(project_row, "name", ""),
max_len=120,
),
"project_description": _normalize_text(getattr(project_row, "description", ""), max_len=220),
"task_titles": [item["title"] for item in tasks],
"signal_titles": [item["title"] for item in signals],
"tasks": tasks,
"signals": signals,
"analysis_object_refs": analysis_object_refs,
}
__all__ = ["load_analysis_project_snapshot"]