from __future__ import annotations import logging import re from config import settings from services.calendar_models import CalendarMeeting, format_meeting_description from services.distributor import DayEntry, _from_units, _to_units from services.grok_client import DEFAULT_PTS_TASK_TYPES, GrokClient from services.teams_calendar_scraper import TeamsCalendarScraper logger = logging.getLogger(__name__) _KEYWORD_TASK_RULES: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"design|architecture|ux|ui review", re.I), "Design"), (re.compile(r"code review|pr review", re.I), "Code Review"), (re.compile(r"sprint|planning|roadmap|project|status", re.I), "Project Management"), (re.compile(r"train|workshop|onboard|tutorial", re.I), "Training"), (re.compile(r"interview", re.I), "Interview"), (re.compile(r"support|customer|escalat", re.I), "Support"), (re.compile(r"incident|postmortem|troubleshoot|debug", re.I), "Trouble Shooting"), (re.compile(r"investigat|root cause", re.I), "Investigating"), (re.compile(r"demo|poc|proof of concept", re.I), "POC"), (re.compile(r"survey|research|requirement", re.I), "Survey"), (re.compile(r"document|spec|write.?up", re.I), "Document"), (re.compile(r"1:1|one on one|manager", re.I), "Manager Task"), ] def _scale_entry_hours(entries: list[DayEntry], target_hours: float, hour_step: float) -> list[DayEntry]: if not entries or target_hours <= 0: return [] current = sum(entry.take_hours for entry in entries) if current <= 0: return entries target_units = _to_units(target_hours, hour_step) if target_units <= 0: return [] scaled: list[DayEntry] = [] allocated = 0 for index, entry in enumerate(entries): if index == len(entries) - 1: units = max(1, target_units - allocated) else: units = max(1, int(round((entry.take_hours / current) * target_units))) allocated += units scaled.append( DayEntry( date=entry.date, description=entry.description, take_hours=_from_units(units, hour_step), gitlab_task_id=entry.gitlab_task_id, pts_task_name=entry.pts_task_name, ) ) return scaled def _guess_meeting_task(subject: str, allowed: list[str], fallback: str) -> str: for pattern, task_name in _KEYWORD_TASK_RULES: if pattern.search(subject): if task_name in allowed: return task_name for option in allowed: if option.lower() == task_name.lower(): return option return fallback if fallback in allowed else (allowed[0] if allowed else "Meeting") async def _load_pts_task_options() -> list[str]: try: from services.pts_client import PTSClient options = await PTSClient().get_task_type_options() names = [str(opt.get("name", "")).strip() for opt in options if not opt.get("disable")] return [name for name in names if name] except Exception as exc: # noqa: BLE001 logger.warning("Could not load PTS task options, using defaults: %s", exc) return list(DEFAULT_PTS_TASK_TYPES) async def _classify_meetings( meeting_payload: list[dict], *, pts_task_options: list[str], ) -> tuple[dict[str, dict[str, str]], dict]: fallback = settings.teams_meeting_task_name or "Meeting" allowed = pts_task_options or list(DEFAULT_PTS_TASK_TYPES) if fallback not in allowed: fallback = _guess_meeting_task("", allowed, "Meeting") grok = GrokClient() if settings.use_llm and grok.enabled and meeting_payload: try: classified, meta = await grok.classify_calendar_meetings( meeting_payload, pts_task_options=allowed, default_task_name=fallback, ) if classified: return classified, meta except Exception as exc: # noqa: BLE001 logger.warning("LLM meeting classification failed, using rules: %s", exc) classified = {} for item in meeting_payload: key = item["key"] subject = str(item.get("subject", "")) task_name = _guess_meeting_task(subject, allowed, fallback) meeting = CalendarMeeting( subject=subject, start=item["start"], end=item["end"], hours=float(item["hours"]), is_online=bool(item.get("is_online")), ) classified[key] = { "pts_task_name": task_name, "description": format_meeting_description(meeting), "reasoning": "rule_based_keyword", } return classified, {"source": "rule_based_meeting_classify", "classified_count": len(classified)} async def merge_meetings_into_plan( plan: dict[str, list[DayEntry]], workday_keys: list[str], ) -> tuple[dict[str, list[DayEntry]], dict]: client = TeamsCalendarScraper() meta: dict = {"enabled": False} if not client.enabled: return plan, meta try: from datetime import date days = [date.fromisoformat(day_key) for day_key in workday_keys] meetings_by_day = await client.fetch_meetings_for_days(days) except Exception as exc: # noqa: BLE001 return plan, {"enabled": True, "error": str(exc)} # Some calendar "meetings" are just automated reminders (e.g. HR clock-out # nudges) that show up fine in the preview list but should never turn into # a PTS work-log line. Filter those out here only — list_meetings()/preview # keeps showing them since it reads fetch_meetings_for_days() directly. exclude_keywords = [kw.lower() for kw in settings.teams_meeting_exclude_keyword_list] excluded_count = 0 if exclude_keywords: filtered_by_day: dict[str, list[CalendarMeeting]] = {} for day_key, meetings in meetings_by_day.items(): kept = [] for meeting in meetings: subject_l = meeting.subject.lower() if any(kw in subject_l for kw in exclude_keywords): excluded_count += 1 continue kept.append(meeting) filtered_by_day[day_key] = kept meetings_by_day = filtered_by_day pts_task_options = await _load_pts_task_options() meeting_payload: list[dict] = [] for day_key in workday_keys: for idx, meeting in enumerate(meetings_by_day.get(day_key, [])): meeting_payload.append( { "key": f"{day_key}#{idx}", "date": day_key, "subject": meeting.subject, "hours": meeting.hours, "is_online": meeting.is_online, "start": meeting.start.isoformat(), "end": meeting.end.isoformat(), } ) classified, classify_meta = await _classify_meetings( meeting_payload, pts_task_options=pts_task_options, ) max_hours = settings.max_hours_per_day hour_step = settings.hour_step merged: dict[str, list[DayEntry]] = {} meeting_count = 0 meeting_hours_total = 0.0 task_breakdown: dict[str, int] = {} for day_key in workday_keys: meetings: list[CalendarMeeting] = meetings_by_day.get(day_key, []) meeting_entries: list[DayEntry] = [] for idx, meeting in enumerate(meetings): key = f"{day_key}#{idx}" info = classified.get(key, {}) task_name = info.get("pts_task_name") or settings.teams_meeting_task_name or "Meeting" description = info.get("description") or format_meeting_description(meeting) meeting_entries.append( DayEntry( date=day_key, description=description, take_hours=meeting.hours, gitlab_task_id=None, pts_task_name=task_name, ) ) task_breakdown[task_name] = task_breakdown.get(task_name, 0) + 1 meeting_hours = round(sum(entry.take_hours for entry in meeting_entries), 1) meeting_count += len(meeting_entries) meeting_hours_total += meeting_hours if meeting_hours >= max_hours: merged[day_key] = _scale_entry_hours(meeting_entries, max_hours, hour_step) continue remaining = max(0.0, max_hours - meeting_hours) gitlab_entries = _scale_entry_hours(plan.get(day_key, []), remaining, hour_step) merged[day_key] = meeting_entries + gitlab_entries meta = { "enabled": True, "meeting_count": meeting_count, "meeting_hours_total": round(meeting_hours_total, 1), "task_breakdown": task_breakdown, "classification": classify_meta, "excluded_count": excluded_count, } return merged, meta