eight-hourr/backend/services/grok_client.py

464 lines
16 KiB
Python
Raw Permalink Normal View History

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