Files
FabledScribe/src/fabledassistant/services/projects.py
T

291 lines
11 KiB
Python

"""Project management service."""
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.project import Project
logger = logging.getLogger(__name__)
async def create_project(
user_id: int,
title: str,
description: str = "",
goal: str = "",
color: str | None = None,
status: str = "active",
) -> Project:
async with async_session() as session:
project = Project(
user_id=user_id,
title=title,
description=description,
goal=goal,
color=color,
status=status,
)
session.add(project)
await session.commit()
await session.refresh(project)
return project
async def get_project(user_id: int, project_id: int) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
return result.scalars().first()
async def get_project_by_title(user_id: int, title: str) -> Project | None:
async with async_session() as session:
result = await session.execute(
select(Project).where(
Project.user_id == user_id,
func.lower(Project.title) == func.lower(title.strip()),
).limit(1)
)
return result.scalars().first()
async def get_or_create_project(user_id: int, title: str) -> Project:
project = await get_project_by_title(user_id, title)
if project:
return project
return await create_project(user_id, title=title)
async def list_projects(user_id: int, status: str | None = None) -> list[Project]:
async with async_session() as session:
query = select(Project).where(Project.user_id == user_id)
if status:
query = query.where(Project.status == status)
query = query.order_by(Project.updated_at.desc())
result = await session.execute(query)
return list(result.scalars().all())
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
import asyncio
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return None
for key, value in fields.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
asyncio.create_task(generate_project_summary(user_id, project_id))
return project
async def generate_project_summary(user_id: int, project_id: int) -> None:
"""Generate an LLM summary for a project and persist it. Fire-and-forget safe."""
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)).scalars().first()
if project is None:
return
note_rows = (await session.execute(
select(Note.title, Note.body)
.where(Note.project_id == project_id, Note.user_id == user_id)
.order_by(Note.updated_at.desc())
.limit(10)
)).all()
title = project.title or ""
description = project.description or ""
goal = project.goal or ""
note_snippets = "\n".join(
f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows
)
prompt = (
f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n"
f"Title: {title}\nDescription: {description}\nGoal: {goal}\n"
f"Recent notes:\n{note_snippets}"
)
from fabledassistant.services.llm import generate_completion
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
messages = [{"role": "user", "content": prompt}]
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
summary = await generate_completion(messages, model=bg_model, max_tokens=400, num_ctx=2048)
if not summary:
return
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project:
project.auto_summary = summary.strip()
project.summary_updated_at = datetime.now(timezone.utc)
await session.commit()
logger.debug("Generated summary for project %d", project_id)
except Exception:
logger.debug("Failed to generate summary for project %d", project_id, exc_info=True)
async def backfill_project_summaries() -> None:
"""Generate summaries for all projects missing auto_summary. Fire-and-forget."""
import asyncio
try:
async with async_session() as session:
rows = (await session.execute(
select(Project.id, Project.user_id).where(Project.auto_summary.is_(None))
)).all()
for row in rows:
asyncio.create_task(generate_project_summary(row.user_id, row.id))
except Exception:
logger.debug("backfill_project_summaries failed", exc_info=True)
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)
project = result.scalars().first()
if project is None:
return False
# Unlink notes (cascade handled by DB ON DELETE SET NULL, but we delete project here)
await session.delete(project)
await session.commit()
return True
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
)
.group_by(Note.status)
)
task_counts: dict[str, int] = {}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from fabledassistant.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
# ---------------------------------------------------------------------------
# Shared-access variants (honour ProjectShare in addition to ownership)
# ---------------------------------------------------------------------------
async def get_project_for_user(
accessing_user_id: int, project_id: int
) -> tuple[Project, str] | None:
"""Returns (project, permission) if user has any access, else None."""
from fabledassistant.services.access import get_project_permission
perm = await get_project_permission(accessing_user_id, project_id)
if perm is None:
return None
async with async_session() as session:
project = await session.get(Project, project_id)
return (project, perm) if project else None
async def list_projects_for_user(user_id: int, status: str | None = None) -> list[dict]:
"""Owned projects + shared projects, each dict has 'permission' field."""
from fabledassistant.models.group import GroupMembership
from fabledassistant.models.share import ProjectShare
from fabledassistant.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned}
result = []
for p in owned:
d = p.to_dict() if hasattr(p, "to_dict") else {"id": p.id, "title": p.title}
d["permission"] = "owner"
result.append(d)
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
shared_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
shared_group: list[ProjectShare] = []
if user_group_ids:
shared_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen: dict[int, str] = {}
for share in list(shared_direct) + list(shared_group):
if share.project_id in owned_ids:
continue
prev = seen.get(share.project_id)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[share.project_id] = share.permission
for pid, perm in seen.items():
if status:
async with async_session() as session:
proj = await session.get(Project, pid)
if not proj or proj.status != status:
continue
else:
async with async_session() as session:
proj = await session.get(Project, pid)
if proj:
d = proj.to_dict() if hasattr(proj, "to_dict") else {"id": proj.id, "title": proj.title}
d["permission"] = perm
d["is_shared"] = True
result.append(d)
return result