464 lines
16 KiB
Python
464 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from datetime import date
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from config import settings
|
||
from services.description_formatter import format_professional_description
|
||
from services.distributor import DayEntry
|
||
from services.gitlab_client import GitLabTask
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
DEFAULT_PTS_TASK_TYPES = [
|
||
"Implement",
|
||
"Meeting",
|
||
"Test",
|
||
"Survey",
|
||
"Bug Fix",
|
||
"Support",
|
||
"Trouble Shooting",
|
||
"Document",
|
||
"Design",
|
||
"Code Review",
|
||
"Training",
|
||
"Project Management",
|
||
"Manager Task",
|
||
"POC",
|
||
"Interview",
|
||
"Investigating",
|
||
"Misc",
|
||
]
|
||
|
||
|
||
class GrokClient:
|
||
def __init__(self) -> None:
|
||
self.api_key = settings.xai_api_key
|
||
self.base_url = settings.xai_base_url.rstrip("/")
|
||
self.model = settings.grok_model
|
||
|
||
@property
|
||
def enabled(self) -> bool:
|
||
return bool(self.api_key)
|
||
|
||
async def plan_pts_entries(
|
||
self,
|
||
tasks: list[GitLabTask],
|
||
workdays: list[date],
|
||
*,
|
||
pts_project_name: str,
|
||
pts_task_name: str,
|
||
max_hours_per_day: float,
|
||
hour_step: float,
|
||
) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]:
|
||
if not self.enabled:
|
||
raise ValueError("XAI_API_KEY is not configured")
|
||
|
||
if not tasks:
|
||
raise ValueError("No GitLab tasks to analyze")
|
||
if not workdays:
|
||
raise ValueError("No workdays in selected range")
|
||
|
||
prompt = self._build_prompt(
|
||
tasks,
|
||
workdays,
|
||
pts_project_name=pts_project_name,
|
||
pts_task_name=pts_task_name,
|
||
max_hours_per_day=max_hours_per_day,
|
||
hour_step=hour_step,
|
||
)
|
||
|
||
raw = await self._chat(prompt)
|
||
parsed = self._parse_json(raw)
|
||
plan = self._to_plan(parsed, tasks, workdays, hour_step, max_hours_per_day)
|
||
analysis = {
|
||
"model": self.model,
|
||
"summary": parsed.get("summary", ""),
|
||
"notes": parsed.get("notes", ""),
|
||
"days": parsed.get("days", []),
|
||
}
|
||
return plan, analysis
|
||
|
||
def _build_prompt(
|
||
self,
|
||
tasks: list[GitLabTask],
|
||
workdays: list[date],
|
||
*,
|
||
pts_project_name: str,
|
||
pts_task_name: str,
|
||
max_hours_per_day: float,
|
||
hour_step: float,
|
||
) -> str:
|
||
task_payload = [
|
||
{
|
||
"id": task.id,
|
||
"reference": task.reference,
|
||
"title": task.title,
|
||
"source": task.source,
|
||
"labels": task.labels or [],
|
||
}
|
||
for task in tasks
|
||
]
|
||
days_payload = [d.isoformat() for d in workdays]
|
||
|
||
return f"""You are a senior software engineer preparing professional PTS timesheet entries for Supermicro.
|
||
|
||
Plan daily work logs based on the GitLab tasks below.
|
||
|
||
## PTS constraints
|
||
- Project (fixed): {pts_project_name}
|
||
- Task type (fixed): {pts_task_name}
|
||
- Plan only: description + take_hours
|
||
- Max {max_hours_per_day} hours per day
|
||
- Hours must be multiples of {hour_step}
|
||
- 1 to 3 entries per day
|
||
- Prioritize board bugs (source=board_bug), then milestone items (source=milestone)
|
||
|
||
## Description style (English only — mandatory)
|
||
Write concise, professional engineering work logs suitable for management review.
|
||
|
||
Rules:
|
||
- English only. No Chinese or other languages.
|
||
- Use past tense or present-perfect tense (e.g., "Investigated", "Implemented", "Refactored", "Validated").
|
||
- Start with a strong action verb.
|
||
- Include the GitLab reference in parentheses, e.g. (super-cloud/scc-flex#1319).
|
||
- Do NOT paste the raw GitLab title verbatim — rewrite as what was accomplished that day.
|
||
- One sentence, 12–25 words.
|
||
- Sound credible and specific, not generic.
|
||
- When the same gitlab_task_id appears on multiple days, each description MUST be a unique paraphrase:
|
||
vary verbs and daily focus (investigated, reproduced, patched, validated, refactored, reviewed, etc.).
|
||
- Never reuse identical description text across entries.
|
||
|
||
Good examples:
|
||
- "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319)."
|
||
- "Refactored Jobs Schedule facade error responses for API consistency (super-cloud/scc-flex#1294)."
|
||
- "Validated firmware baseline history progress calculation under multi-target runs (super-cloud/scc-flex#1296)."
|
||
|
||
Bad examples:
|
||
- "Worked on bug" (too vague)
|
||
- Copy-paste of full GitLab title without rewrite
|
||
- Any non-English text
|
||
|
||
## Workdays
|
||
{json.dumps(days_payload)}
|
||
|
||
## GitLab tasks
|
||
{json.dumps(task_payload, indent=2)}
|
||
|
||
Return JSON only:
|
||
{{
|
||
"summary": "Brief English summary of the overall plan",
|
||
"notes": "English explanation of allocation logic",
|
||
"days": [
|
||
{{
|
||
"date": "YYYY-MM-DD",
|
||
"entries": [
|
||
{{
|
||
"gitlab_task_id": 123,
|
||
"description": "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319).",
|
||
"take_hours": 2.5,
|
||
"reasoning": "English rationale for task selection and hours"
|
||
}}
|
||
]
|
||
}}
|
||
]
|
||
}}
|
||
"""
|
||
|
||
async def _chat(self, prompt: str, *, temperature: float = 0.2) -> str:
|
||
payload = {
|
||
"model": self.model,
|
||
"messages": [
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"You are a professional technical writing assistant. "
|
||
"Output valid JSON only. All text fields must be in English."
|
||
),
|
||
},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
"temperature": temperature,
|
||
"max_tokens": 4096,
|
||
}
|
||
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
response = await client.post(
|
||
f"{self.base_url}/v1/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json=payload,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
content = data["choices"][0]["message"]["content"]
|
||
if not content:
|
||
raise ValueError("Grok returned empty content")
|
||
return content
|
||
|
||
def _parse_json(self, text: str) -> dict[str, Any]:
|
||
cleaned = text.strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||
|
||
try:
|
||
return json.loads(cleaned)
|
||
except json.JSONDecodeError:
|
||
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
||
if not match:
|
||
raise ValueError("Grok response is not valid JSON") from None
|
||
return json.loads(match.group())
|
||
|
||
def _to_plan(
|
||
self,
|
||
parsed: dict[str, Any],
|
||
tasks: list[GitLabTask],
|
||
workdays: list[date],
|
||
hour_step: float,
|
||
max_hours_per_day: float,
|
||
) -> dict[str, list[DayEntry]]:
|
||
valid_ids = {task.id for task in tasks}
|
||
task_by_id = {task.id: task for task in tasks}
|
||
allowed_days = {d.isoformat() for d in workdays}
|
||
plan: dict[str, list[DayEntry]] = {d.isoformat(): [] for d in workdays}
|
||
|
||
for day_block in parsed.get("days", []):
|
||
day = str(day_block.get("date", ""))
|
||
if day not in allowed_days:
|
||
continue
|
||
|
||
entries: list[DayEntry] = []
|
||
total_hours = 0.0
|
||
for item in day_block.get("entries", []):
|
||
task_id = item.get("gitlab_task_id")
|
||
if task_id not in valid_ids:
|
||
continue
|
||
|
||
hours = float(item.get("take_hours", 0))
|
||
hours = round(round(hours / hour_step) * hour_step, 1)
|
||
if hours <= 0:
|
||
continue
|
||
if total_hours + hours > max_hours_per_day:
|
||
hours = max(0.0, max_hours_per_day - total_hours)
|
||
hours = round(round(hours / hour_step) * hour_step, 1)
|
||
if hours <= 0:
|
||
continue
|
||
|
||
description = str(item.get("description", "")).strip()
|
||
if not description or self._contains_cjk(description):
|
||
description = format_professional_description(task_by_id[task_id])
|
||
|
||
entries.append(
|
||
DayEntry(
|
||
date=day,
|
||
description=description,
|
||
take_hours=hours,
|
||
gitlab_task_id=task_id,
|
||
)
|
||
)
|
||
total_hours += hours
|
||
|
||
if entries:
|
||
plan[day] = entries
|
||
|
||
if not any(plan.values()):
|
||
raise ValueError("Grok plan contained no valid entries")
|
||
|
||
return plan
|
||
|
||
def _contains_cjk(self, text: str) -> bool:
|
||
return bool(re.search(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
|
||
|
||
def _match_task_name(self, name: str, allowed: list[str], fallback: str) -> str:
|
||
cleaned = str(name or "").strip()
|
||
if cleaned in allowed:
|
||
return cleaned
|
||
lowered = cleaned.lower()
|
||
for option in allowed:
|
||
if lowered == option.lower():
|
||
return option
|
||
for option in allowed:
|
||
if lowered in option.lower() or option.lower() in lowered:
|
||
return option
|
||
return fallback if fallback in allowed else allowed[0]
|
||
|
||
async def classify_calendar_meetings(
|
||
self,
|
||
meetings: list[dict[str, Any]],
|
||
*,
|
||
pts_task_options: list[str],
|
||
default_task_name: str,
|
||
) -> tuple[dict[str, dict[str, str]], dict[str, Any]]:
|
||
if not meetings:
|
||
return {}, {"source": "skipped", "reason": "no_meetings"}
|
||
|
||
allowed = pts_task_options or DEFAULT_PTS_TASK_TYPES
|
||
fallback = self._match_task_name(default_task_name, allowed, "Meeting")
|
||
|
||
prompt = f"""You classify Microsoft Teams / Outlook calendar events into PTS timesheet task types for Supermicro engineers.
|
||
|
||
For EACH meeting below, pick the best PTS task type and write a professional English work-log description.
|
||
|
||
## Allowed PTS task types (pick exactly one per meeting)
|
||
{json.dumps(allowed, ensure_ascii=False)}
|
||
|
||
## Classification guide
|
||
- Standups, syncs, team meetings, 1:1s, general discussions -> Meeting
|
||
- Sprint planning, roadmap, status review, project sync -> Project Management
|
||
- Design review, architecture review, UI/UX review -> Design
|
||
- Code review, PR review -> Code Review
|
||
- Training, workshop, onboarding session -> Training
|
||
- Customer call, support escalation, troubleshooting call -> Support or Trouble Shooting
|
||
- Interview (candidate) -> Interview
|
||
- Demo, POC review -> POC
|
||
- Requirements gathering, user research -> Survey
|
||
- Incident/postmortem investigation -> Investigating
|
||
- Documentation walkthrough -> Document
|
||
|
||
## Description rules
|
||
- English only, one sentence, 12–25 words, past tense
|
||
- Mention Teams/meeting context naturally
|
||
- Do NOT change take_hours
|
||
|
||
## Meetings
|
||
{json.dumps(meetings, ensure_ascii=False, indent=2)}
|
||
|
||
Return JSON only:
|
||
{{
|
||
"summary": "brief note on classification approach",
|
||
"meetings": [
|
||
{{
|
||
"key": "YYYY-MM-DD#0",
|
||
"pts_task_name": "Meeting",
|
||
"description": "Attended sprint planning for SCC v3.9 release scope.",
|
||
"reasoning": "sprint planning keyword"
|
||
}}
|
||
]
|
||
}}
|
||
"""
|
||
|
||
raw = await self._chat(prompt, temperature=0.3)
|
||
data = self._parse_json(raw)
|
||
classified: dict[str, dict[str, str]] = {}
|
||
for item in data.get("meetings", []):
|
||
key = str(item.get("key", "")).strip()
|
||
if not key:
|
||
continue
|
||
description = str(item.get("description", "")).strip()
|
||
if not description or self._contains_cjk(description):
|
||
continue
|
||
task_name = self._match_task_name(
|
||
str(item.get("pts_task_name", "")),
|
||
allowed,
|
||
fallback,
|
||
)
|
||
classified[key] = {
|
||
"pts_task_name": task_name,
|
||
"description": description,
|
||
"reasoning": str(item.get("reasoning", "")).strip(),
|
||
}
|
||
|
||
return classified, {
|
||
"source": "grok_meeting_classify",
|
||
"summary": data.get("summary", ""),
|
||
"classified_count": len(classified),
|
||
}
|
||
|
||
async def diversify_descriptions(
|
||
self,
|
||
plan: dict[str, list[DayEntry]],
|
||
tasks: list[GitLabTask],
|
||
) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]:
|
||
flat: list[dict[str, Any]] = []
|
||
for day_key in sorted(plan.keys()):
|
||
for idx, entry in enumerate(plan[day_key]):
|
||
flat.append(
|
||
{
|
||
"key": f"{day_key}#{idx}",
|
||
"date": day_key,
|
||
"gitlab_task_id": entry.gitlab_task_id,
|
||
"current_description": entry.description,
|
||
"take_hours": entry.take_hours,
|
||
}
|
||
)
|
||
|
||
if len(flat) <= 1:
|
||
return plan, {"source": "variation_skipped", "reason": "single_entry"}
|
||
|
||
task_by_id = {t.id: t for t in tasks}
|
||
task_context = [
|
||
{
|
||
"id": t.id,
|
||
"reference": t.reference,
|
||
"title": t.title,
|
||
"source": t.source,
|
||
}
|
||
for t in tasks
|
||
]
|
||
|
||
prompt = f"""Rewrite PTS timesheet descriptions for Supermicro engineering work logs.
|
||
|
||
For EACH entry below, produce a UNIQUE professional English description that:
|
||
- Keeps the same GitLab issue reference and gitlab_task_id when present
|
||
- Stays faithful to the task title / current description — describe plausible daily engineering work
|
||
- Uses different wording, verbs, and focus than every other entry in this batch
|
||
- Sounds like a real one-day work log (one sentence, 12–25 words), not copy-paste
|
||
- English only. Past tense or present-perfect. Start with a strong action verb.
|
||
- Do NOT change dates or take_hours
|
||
|
||
GitLab tasks (context):
|
||
{json.dumps(task_context, ensure_ascii=False, indent=2)}
|
||
|
||
Entries to rewrite:
|
||
{json.dumps(flat, ensure_ascii=False, indent=2)}
|
||
|
||
Return JSON only:
|
||
{{
|
||
"summary": "short English note on variation approach",
|
||
"entries": [
|
||
{{"key": "YYYY-MM-DD#0", "description": "unique English work log"}}
|
||
]
|
||
}}
|
||
"""
|
||
|
||
raw = await self._chat(prompt, temperature=0.65)
|
||
data = self._parse_json(raw)
|
||
rewritten = {
|
||
item["key"]: str(item.get("description", "")).strip()
|
||
for item in data.get("entries", [])
|
||
if item.get("key")
|
||
}
|
||
if not rewritten:
|
||
raise ValueError("Grok variation returned no entries")
|
||
|
||
new_plan: dict[str, list[DayEntry]] = {}
|
||
for day_key in sorted(plan.keys()):
|
||
new_plan[day_key] = []
|
||
for idx, entry in enumerate(plan[day_key]):
|
||
key = f"{day_key}#{idx}"
|
||
description = rewritten.get(key, entry.description)
|
||
if (not description or self._contains_cjk(description)) and entry.gitlab_task_id in task_by_id:
|
||
description = format_professional_description(task_by_id[entry.gitlab_task_id])
|
||
new_plan[day_key].append(
|
||
DayEntry(
|
||
date=day_key,
|
||
description=description,
|
||
take_hours=entry.take_hours,
|
||
gitlab_task_id=entry.gitlab_task_id,
|
||
)
|
||
)
|
||
|
||
return new_plan, {
|
||
"source": "grok_variation",
|
||
"summary": data.get("summary", ""),
|
||
"rewritten_count": len(rewritten),
|
||
} |