232 lines
8.8 KiB
Python
232 lines
8.8 KiB
Python
|
|
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
|