eight-hourr/backend/services/meeting_planner.py

232 lines
8.8 KiB
Python
Raw Permalink Normal View History

2026-07-17 06:55:36 +00:00
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