Merge codeberg/main
This commit is contained in:
commit
6af7b388f8
46 changed files with 6498 additions and 4 deletions
149
.agents/skills/check-my-timesheet/SKILL.md
Normal file
149
.agents/skills/check-my-timesheet/SKILL.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
---
|
||||
name: check-my-timesheet
|
||||
description: >
|
||||
Two behaviours in one skill:
|
||||
(1) CHECK — show the current user's timesheet gaps for a given period. Compares
|
||||
Tempo worklogs against the working calendar and flags missing or partial days.
|
||||
(2) LOG — help the user log time to Tempo. User provides a description or issue key;
|
||||
Claude searches Jira if needed, confirms the entry, then posts it.
|
||||
TRIGGER check when user says: "check my timesheet", "timesheet gaps", "where am I missing
|
||||
hours", "did I log all hours", "show my missing time".
|
||||
TRIGGER log when user says: "log time", "add time entry", "log hours", "report hours",
|
||||
"book time on", "add worklog".
|
||||
---
|
||||
|
||||
# Check My Timesheet
|
||||
|
||||
Personal timesheet checker and time logger for the user identified by `JIRA_EMAIL`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
All configuration lives in the `.env` file in the project root:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Read and write Tempo worklogs | — |
|
||||
| `JIRA_BASE_URL` | Resolve account ID and search issues | — |
|
||||
| `JIRA_EMAIL` | Identifies whose timesheet to read / who is logging | — |
|
||||
| `JIRA_API_TOKEN` | Authenticate with Jira | — |
|
||||
| `MY_TIMESHEET_COUNTRY` | Country code for public holiday calendar (`PL`, `GB`, `DE`…) | `PL` |
|
||||
| `MY_TIMESHEET_HOURS_PER_DAY` | Expected working hours per day | `8` |
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour 1 — Check timesheet gaps
|
||||
|
||||
### Trigger phrases
|
||||
"check my timesheet", "timesheet gaps", "where am I missing hours", "did I log all hours",
|
||||
"show my time", "show my missing time"
|
||||
|
||||
### Workflow
|
||||
|
||||
**Step 1 — Ask for period.** Always ask — never assume a default:
|
||||
> "Which period should I check? (e.g. this week, last week, June 2026, or a date range)"
|
||||
|
||||
Convert natural language to `YYYY-MM-DD:YYYY-MM-DD`:
|
||||
- "this week" → Monday of current week to yesterday
|
||||
- "last week" → previous Monday–Friday
|
||||
- "June 2026" → `2026-06-01:2026-06-30`
|
||||
- "today" → today:today
|
||||
|
||||
**Step 2 — Run check:**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/check.py \
|
||||
--period {YYYY-MM-DD:YYYY-MM-DD} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
**Step 3 — Present results.** Read the output JSON and show:
|
||||
|
||||
1. Summary: `Logged X h / Y h expected (Z working days)`
|
||||
2. If `missing_hours == 0`: "✓ Timesheet complete for this period."
|
||||
3. Otherwise, a table of all working days — highlight missing and partial:
|
||||
|
||||
```
|
||||
| Date | Logged | Status |
|
||||
| 2026-06-23 | 8.0 h | ✓ OK |
|
||||
| 2026-06-24 | 4.0 h | ⚠ Partial (4h missing) |
|
||||
| 2026-06-25 | 0.0 h | ✗ Missing |
|
||||
```
|
||||
|
||||
4. List only the problem days prominently if there are many OK days.
|
||||
|
||||
---
|
||||
|
||||
## Behaviour 2 — Log time
|
||||
|
||||
### Trigger phrases
|
||||
"log time", "add time entry", "log hours", "report hours", "book time on", "add worklog",
|
||||
"log X hours on", "log X hours for"
|
||||
|
||||
### Workflow
|
||||
|
||||
**Step 1 — Collect what is needed:**
|
||||
|
||||
From the user's message extract or ask for:
|
||||
- **Issue** — Jira issue key (e.g. `IAA-42`) or a description to search by
|
||||
- **Hours** — number of hours (decimals OK: 1.5 = 1h30m)
|
||||
- **Date** — which day to log on (default: today if not mentioned)
|
||||
- **Description** — optional note for the worklog
|
||||
|
||||
If issue key is not provided but a description is, run a search:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode search \
|
||||
--query "{description}"
|
||||
```
|
||||
|
||||
Present the results (up to 5 issues) and ask the user to pick one or confirm.
|
||||
If no results found, ask the user to provide the issue key directly.
|
||||
|
||||
**Step 2 — Preview (run without --confirmed).** Always run the preview step first:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode log \
|
||||
--issue {KEY} \
|
||||
--hours {N} \
|
||||
--date {YYYY-MM-DD} \
|
||||
--description "{description}"
|
||||
```
|
||||
|
||||
The script prints the pending worklog and exits without writing anything.
|
||||
If hours exceed `MY_TIMESHEET_HOURS_PER_DAY` it also prints a `⚠ WARNING` line.
|
||||
|
||||
**Step 3 — Show preview to user and ask for confirmation.** Present the full script output
|
||||
including any warnings. Wait for explicit yes/no. Do NOT proceed without explicit confirmation.
|
||||
|
||||
**Step 4 — Log (only after yes, with --confirmed):**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/log.py \
|
||||
--mode log \
|
||||
--issue {KEY} \
|
||||
--hours {N} \
|
||||
--date {YYYY-MM-DD} \
|
||||
--description "{description}" \
|
||||
--confirmed
|
||||
```
|
||||
|
||||
**Step 5 — Report result.** Show the worklog ID and a confirmation message.
|
||||
Offer to check the timesheet again to verify the entry is reflected.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never log time without explicit user confirmation.
|
||||
- Always run the preview step first — the `--confirmed` flag must only be added after the user says yes.
|
||||
- If the preview shows a `⚠ WARNING` (hours exceed daily limit), make sure the user acknowledges it before confirming.
|
||||
- If `my_timesheet.md` is missing, stop and show the user the template.
|
||||
- If `JIRA_EMAIL` is not set, stop and ask the user to set it in `.env`.
|
||||
- If any script step fails, show the error and stop.
|
||||
- Do not guess the issue key — always search or ask if unsure.
|
||||
250
.agents/skills/check-my-timesheet/scripts/check.py
Normal file
250
.agents/skills/check-my-timesheet/scripts/check.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check the current user's timesheet for gaps against the working calendar."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Config loading ────────────────────────────────────────────────────────────
|
||||
|
||||
def load_config() -> dict[str, Any]:
|
||||
"""Read timesheet config from env vars MY_TIMESHEET_COUNTRY and MY_TIMESHEET_HOURS_PER_DAY."""
|
||||
country = os.environ.get("MY_TIMESHEET_COUNTRY", "PL").strip().upper()
|
||||
raw_hpd = os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8").strip()
|
||||
try:
|
||||
hours_per_day = float(raw_hpd)
|
||||
except ValueError:
|
||||
print(f"Warning: invalid MY_TIMESHEET_HOURS_PER_DAY '{raw_hpd}' — using 8.", file=sys.stderr)
|
||||
hours_per_day = 8.0
|
||||
return {"country": country, "hours_per_day": hours_per_day}
|
||||
|
||||
|
||||
# ── Period parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_period(period_str: str) -> tuple[date, date]:
|
||||
s = period_str.strip()
|
||||
if ":" in s:
|
||||
a, b = s.split(":", 1)
|
||||
return date.fromisoformat(a.strip()), date.fromisoformat(b.strip())
|
||||
raise ValueError(f"Invalid period '{period_str}'. Use YYYY-MM-DD:YYYY-MM-DD.")
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_holidays(country: str, years: set[int]) -> set[date]:
|
||||
holidays: set[date] = set()
|
||||
for year in sorted(years):
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "check-my-timesheet/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
holidays |= {date.fromisoformat(i["date"]) for i in json.loads(r.read())}
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
|
||||
return holidays
|
||||
|
||||
|
||||
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
result, cur = [], start
|
||||
while cur <= end:
|
||||
if cur.weekday() < 5 and cur not in holidays:
|
||||
result.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Jira account lookup ───────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
users = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira 401. Check JIRA_EMAIL and JIRA_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise
|
||||
match = next((u for u in users if u.get("emailAddress", "").lower() == email.lower()), None)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
# ── Tempo worklogs ────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_worklogs(account_id: str, start: date, end: date,
|
||||
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
|
||||
results: list[dict] = []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = (
|
||||
f"{base_url}/worklogs/user/{account_id}?from={start}&to={end}&limit=1000"
|
||||
)
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
|
||||
results.extend(data.get("results", []))
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
return results
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [("TEMPO_API_TOKEN", tempo_token), ("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token)] if not v]
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cfg = load_config()
|
||||
print(f"Config: country={cfg['country']}, hours_per_day={cfg['hours_per_day']}")
|
||||
|
||||
try:
|
||||
start, end = parse_period(args.period)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Period: {start} – {end}")
|
||||
print(f"User: {jira_email}")
|
||||
|
||||
print("Resolving Jira account...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
print(f" {jira_email} → {account_id}")
|
||||
|
||||
print("Fetching public holidays...")
|
||||
holidays = get_holidays(cfg["country"], {start.year, end.year})
|
||||
wdays = working_days(start, end, holidays)
|
||||
expected_hours = len(wdays) * cfg["hours_per_day"]
|
||||
print(f" Working days: {len(wdays)} Expected: {expected_hours:.0f}h")
|
||||
|
||||
print("Fetching Tempo worklogs...")
|
||||
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
|
||||
print(f" Worklogs: {len(worklogs)}")
|
||||
|
||||
# Aggregate hours per date
|
||||
hours_by_date: dict[str, float] = {}
|
||||
for wl in worklogs:
|
||||
d = wl.get("startDate", "")
|
||||
if d:
|
||||
hours_by_date[d] = hours_by_date.get(d, 0.0) + wl.get("timeSpentSeconds", 0) / 3600
|
||||
|
||||
# Classify each working day
|
||||
hpd = cfg["hours_per_day"]
|
||||
days_out = []
|
||||
for d in wdays:
|
||||
logged = round(hours_by_date.get(d.isoformat(), 0.0), 2)
|
||||
if logged >= hpd:
|
||||
status = "ok"
|
||||
entry = {"date": d.isoformat(), "logged_hours": logged, "status": "ok"}
|
||||
elif logged > 0:
|
||||
missing_h = round(hpd - logged, 2)
|
||||
status = "partial"
|
||||
entry = {"date": d.isoformat(), "logged_hours": logged,
|
||||
"status": "partial", "missing": missing_h}
|
||||
else:
|
||||
status = "missing"
|
||||
entry = {"date": d.isoformat(), "logged_hours": 0.0,
|
||||
"status": "missing", "missing": hpd}
|
||||
days_out.append(entry)
|
||||
|
||||
total_logged = round(sum(hours_by_date.get(d.isoformat(), 0.0) for d in wdays), 2)
|
||||
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
|
||||
|
||||
output = {
|
||||
"period": {"start": str(start), "end": str(end)},
|
||||
"config": cfg,
|
||||
"working_days": len(wdays),
|
||||
"expected_hours": expected_hours,
|
||||
"total_logged_hours": total_logged,
|
||||
"missing_hours": missing_hours,
|
||||
"days": days_out,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"my_check_{start}_{end}.json"
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
# Quick summary
|
||||
problem_days = [d for d in days_out if d["status"] != "ok"]
|
||||
print(f"\nLogged: {total_logged:.1f}h / {expected_hours:.0f}h expected")
|
||||
if not problem_days:
|
||||
print("✓ Timesheet complete.")
|
||||
else:
|
||||
missing_days = [d for d in problem_days if d["status"] == "missing"]
|
||||
partial_days = [d for d in problem_days if d["status"] == "partial"]
|
||||
if missing_days:
|
||||
print(f"✗ Missing ({len(missing_days)} day(s)): "
|
||||
+ ", ".join(d["date"] for d in missing_days))
|
||||
if partial_days:
|
||||
print(f"⚠ Partial ({len(partial_days)} day(s)): "
|
||||
+ ", ".join(f"{d['date']} ({d['logged_hours']}h)" for d in partial_days))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Check timesheet gaps for the current user.")
|
||||
p.add_argument("--period", required=True, help="YYYY-MM-DD:YYYY-MM-DD")
|
||||
p.add_argument("--output-dir", required=True)
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
280
.agents/skills/check-my-timesheet/scripts/log.py
Normal file
280
.agents/skills/check-my-timesheet/scripts/log.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Search Jira issues or log a time entry to Tempo.
|
||||
|
||||
Modes:
|
||||
--mode search --query "text"
|
||||
Search Jira for matching issues. Returns JSON list of matches.
|
||||
|
||||
--mode log --issue KEY --hours N --date YYYY-MM-DD [--description "..."]
|
||||
Post a worklog to Tempo. Requires confirmation from the caller.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Jira helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def jira_headers(jira_email: str, jira_token: str) -> dict:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
return {"Authorization": f"Basic {creds}", "Accept": "application/json",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
users = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira 401. Check JIRA_EMAIL and JIRA_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise
|
||||
match = next((u for u in users if u.get("emailAddress", "").lower() == email.lower()), None)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
def search_issues(query: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> list[dict]:
|
||||
"""Search Jira for issues matching the query. Uses POST /search/jql (v3)."""
|
||||
body = json.dumps({
|
||||
"jql": f'text ~ "{query}" ORDER BY updated DESC',
|
||||
"fields": ["key", "summary", "status", "assignee"],
|
||||
"maxResults": 5,
|
||||
}).encode()
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/search/jql"
|
||||
req = urllib.request.Request(url, data=body,
|
||||
headers=jira_headers(jira_email, jira_token),
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
data = json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_txt = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Jira search failed ({exc.code}): {body_txt[:300]}") from exc
|
||||
results = []
|
||||
for issue in data.get("issues", []):
|
||||
fields = issue.get("fields", {})
|
||||
results.append({
|
||||
"key": issue["key"],
|
||||
"summary": fields.get("summary", ""),
|
||||
"status": fields.get("status", {}).get("name", ""),
|
||||
"assignee": (fields.get("assignee") or {}).get("displayName", "unassigned"),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def get_issue(issue_key: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> dict:
|
||||
"""Fetch id and summary of a Jira issue. Returns {"id": int, "summary": str}."""
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/issue/{issue_key}?fields=summary,id"
|
||||
req = urllib.request.Request(url, headers=jira_headers(jira_email, jira_token))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
data = json.loads(r.read())
|
||||
return {
|
||||
"id": int(data.get("id", 0)),
|
||||
"summary": data.get("fields", {}).get("summary", ""),
|
||||
}
|
||||
except Exception:
|
||||
return {"id": 0, "summary": ""}
|
||||
|
||||
|
||||
# ── Tempo log ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def log_worklog(account_id: str, issue_id: int, hours: float,
|
||||
log_date: str, description: str, tempo_token: str,
|
||||
base_url: str = "https://api.tempo.io/4") -> dict:
|
||||
"""POST a new worklog to Tempo. Returns the created worklog data."""
|
||||
body = {
|
||||
"issueId": issue_id,
|
||||
"timeSpentSeconds": int(hours * 3600),
|
||||
"startDate": log_date,
|
||||
"startTime": "09:00:00",
|
||||
"authorAccountId": account_id,
|
||||
}
|
||||
if description:
|
||||
body["description"] = description
|
||||
|
||||
data = json.dumps(body).encode()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {tempo_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
req = urllib.request.Request(f"{base_url}/worklogs", data=data,
|
||||
headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body_txt = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body_txt[:300]}") from exc
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email),
|
||||
("JIRA_API_TOKEN", jira_token)] if not v]
|
||||
if args.mode == "log" and not tempo_token:
|
||||
missing.append("TEMPO_API_TOKEN")
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Search mode ───────────────────────────────────────────────────────────
|
||||
if args.mode == "search":
|
||||
if not args.query:
|
||||
print("Error: --query is required for search mode.", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Searching Jira for: {args.query!r}")
|
||||
try:
|
||||
results = search_issues(args.query, jira_base_url, jira_email, jira_token)
|
||||
except RuntimeError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
if not results:
|
||||
print("No issues found.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# ── Log mode ──────────────────────────────────────────────────────────────
|
||||
if not args.issue:
|
||||
print("Error: --issue is required for log mode.", file=sys.stderr)
|
||||
return 1
|
||||
if not args.hours or args.hours <= 0:
|
||||
print("Error: --hours must be a positive number.", file=sys.stderr)
|
||||
return 1
|
||||
if not args.date:
|
||||
print("Error: --date is required for log mode (YYYY-MM-DD).", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
issue_key = args.issue.strip().upper()
|
||||
if not re.fullmatch(r"[A-Z][A-Z0-9]+-\d+", issue_key):
|
||||
print(f"Error: '{issue_key}' is not a valid Jira issue key (expected e.g. IAA-42).",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Read configured daily limit for over-hours warning
|
||||
try:
|
||||
hours_per_day = float(os.environ.get("MY_TIMESHEET_HOURS_PER_DAY", "8"))
|
||||
except ValueError:
|
||||
hours_per_day = 8.0
|
||||
|
||||
print(f"Resolving Jira account for {jira_email}...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
|
||||
# Fetch issue ID and summary (Tempo v4 requires numeric issueId, not issueKey)
|
||||
issue_info = get_issue(issue_key, jira_base_url, jira_email, jira_token)
|
||||
issue_id = issue_info["id"]
|
||||
summary = issue_info["summary"]
|
||||
if not issue_id:
|
||||
print(f"Error: could not resolve numeric ID for {issue_key}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# ── Guardrail: over-hours warning ─────────────────────────────────────────
|
||||
if args.hours > hours_per_day:
|
||||
print(
|
||||
f"\n⚠ WARNING: {args.hours}h exceeds the configured daily limit "
|
||||
f"of {hours_per_day}h (MY_TIMESHEET_HOURS_PER_DAY).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# ── Preview (always shown) ────────────────────────────────────────────────
|
||||
print(f"\nPending worklog:")
|
||||
print(f" Issue: {issue_key}" + (f" — {summary}" if summary else ""))
|
||||
print(f" Hours: {args.hours}h")
|
||||
print(f" Date: {args.date}")
|
||||
if args.description:
|
||||
print(f" Note: {args.description}")
|
||||
|
||||
# ── Guardrail: require explicit confirmation ───────────────────────────────
|
||||
if not args.confirmed:
|
||||
print(
|
||||
"\n⏸ Not logged. Re-run with --confirmed to post this worklog to Tempo."
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
result = log_worklog(
|
||||
account_id=account_id,
|
||||
issue_id=issue_id,
|
||||
hours=args.hours,
|
||||
log_date=args.date,
|
||||
description=args.description or "",
|
||||
tempo_token=tempo_token,
|
||||
)
|
||||
worklog_id = result.get("tempoWorklogId") or result.get("id", "?")
|
||||
print(f"\n✓ Worklog logged. Tempo ID: {worklog_id}")
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"\nError: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Search Jira or log time to Tempo.")
|
||||
p.add_argument("--mode", required=True, choices=["search", "log"])
|
||||
p.add_argument("--query", help="Search text (search mode)")
|
||||
p.add_argument("--issue", help="Jira issue key, e.g. IAA-42 (log mode)")
|
||||
p.add_argument("--hours", type=float, help="Hours to log, e.g. 2.5 (log mode)")
|
||||
p.add_argument("--date", help="Date YYYY-MM-DD (log mode)")
|
||||
p.add_argument("--description", default="", help="Optional worklog note (log mode)")
|
||||
p.add_argument("--confirmed", action="store_true",
|
||||
help="Must be passed to actually post the worklog. Without it the script "
|
||||
"only shows a preview.")
|
||||
return p
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
BIN
.agents/skills/clouddrift-docx/.DS_Store
vendored
Normal file
BIN
.agents/skills/clouddrift-docx/.DS_Store
vendored
Normal file
Binary file not shown.
141
.agents/skills/clouddrift-docx/SKILL.md
Normal file
141
.agents/skills/clouddrift-docx/SKILL.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
---
|
||||
name: clouddrift-docx
|
||||
description: Convert any Markdown file into a Cloud Drift branded .docx or .pdf using pandoc and a custom reference template (fonts, colors, logo, footer pagination pulled from Cloud Drift's brand guide and case-study docx). Use when the user asks to "export as a Cloud Drift branded doc/pdf", "convert this markdown to a Cloud Drift docx", "make this look like our case study", or wants a client-ready styled document from a markdown file.
|
||||
---
|
||||
|
||||
# Cloud Drift branded docx/pdf export
|
||||
|
||||
## Purpose
|
||||
|
||||
Turn any Markdown file into a document that looks like it came from Cloud
|
||||
Drift's own brand: Open Sans / Open Sans Light typography, the Fire
|
||||
Opal / White Coffee / Raisin Black color system, the "Cloud Drift." logo in
|
||||
a running header, and page-numbered footers. Output can be `.docx`,
|
||||
`.pdf`, or both.
|
||||
|
||||
The brand spec was extracted directly from `tmp/Cloud_Drift_CI_v1b 2.pdf`
|
||||
(the CI/brand guide) and cross-checked against the real embedded fonts/
|
||||
colors/logo inside `tmp/Branded [case study] ... .docx` — this isn't a
|
||||
guess at "corporate-looking" styling, it replicates the actual brand:
|
||||
|
||||
- **Font:** Open Sans Light (body, title, H1), Open Sans regular/bold (H2/H3, table headers)
|
||||
- **Colors:** Fire Opal `#E45249` (primary/accent — title, H2, hyperlinks, blockquote bar), White Coffee `#E8DCD0` (table shading/borders), Raisin Black `#252525` (body text, H1)
|
||||
- **Logo:** "Cloud Drift." wordmark, placed in the page header on every page
|
||||
- **Layout:** A4, 1" margins, footer with "Page X of Y"
|
||||
|
||||
## How this fits together
|
||||
|
||||
1. `assets/clouddrift-reference.docx` — the pandoc reference-doc. Pandoc
|
||||
clones this file's styles (Normal, Title, Heading 1-6, Block Text,
|
||||
Hyperlink, Table), page setup, and header/footer (including the logo)
|
||||
into whatever it generates. The Open Sans / Open Sans Light fonts are
|
||||
embedded inside it (unobfuscated TTF parts, same scheme the original
|
||||
Cloud Drift case-study docx uses), so Word will render it correctly even
|
||||
on a machine that doesn't have the fonts installed.
|
||||
2. `assets/fonts/*.ttf` — the same 8 font files, for reinstalling the fonts
|
||||
locally if needed (see below).
|
||||
3. `assets/clouddrift-logo.png` — the extracted logo, already baked into
|
||||
the reference doc's header; kept here for reuse elsewhere if needed.
|
||||
4. `scripts/convert.py` — the conversion driver (see usage below).
|
||||
5. `scripts/build_reference.py` + `scripts/embed_fonts.py` — the one-time
|
||||
scripts that built `clouddrift-reference.docx` from pandoc's own default
|
||||
reference doc. Only needed again if the brand changes (new colors, new
|
||||
logo, different heading scheme) — see "Regenerating the template" below.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python3 .claude/skills/clouddrift-docx/scripts/convert.py INPUT.md --format docx
|
||||
python3 .claude/skills/clouddrift-docx/scripts/convert.py INPUT.md --format pdf
|
||||
python3 .claude/skills/clouddrift-docx/scripts/convert.py INPUT.md --format both --output outputs/documents/my-doc
|
||||
```
|
||||
|
||||
- `--format` — `docx` (default), `pdf`, or `both`.
|
||||
- `--output` — path without extension; defaults to the input's own path/name.
|
||||
- `--reference-doc` — override the template (rarely needed).
|
||||
|
||||
The `.docx` step is pure pandoc (`pandoc INPUT.md -o OUTPUT.docx --reference-doc=...`).
|
||||
PDF export works by asking macOS **Pages** to open that generated `.docx`
|
||||
and export it to PDF — this guarantees the PDF is pixel-identical to the
|
||||
branded docx rather than a second, independently-maintained template. This
|
||||
means:
|
||||
- **macOS only.** There's no PDF engine pandoc can drive directly in this
|
||||
environment, so this is the practical path rather than building a
|
||||
parallel LaTeX/CSS template.
|
||||
- **First run may need a permission grant.** macOS will prompt to let the
|
||||
automating process control Pages (System Settings → Privacy & Security →
|
||||
Automation). Approve it once.
|
||||
- **If invoked through Claude Code's Bash tool**, the PDF step needs
|
||||
`dangerouslyDisableSandbox: true` — sandboxed Bash can't send Apple
|
||||
Events to GUI apps like Pages. The docx-only step does not need this.
|
||||
- If Pages returns "Connection is invalid" on the very first call, it
|
||||
usually means Pages hadn't finished launching yet — retry once.
|
||||
|
||||
**Why not `pandoc --pdf-engine=...` directly?** Tried this on 2026-07-14 —
|
||||
installed `tectonic` (a self-contained LaTeX engine) specifically so pandoc
|
||||
could produce PDF natively. It failed: tectonic fetches its TeX resource
|
||||
bundle from `relay.fullyjustified.net` on first use, and that domain
|
||||
resolves to `0.0.0.0` on this network (a DNS-level filter, not something to
|
||||
route around). General internet access otherwise works fine — it's specific
|
||||
to that host. Asked the user how to proceed; they chose to keep the
|
||||
Pages-based PDF path rather than switch to a bigger `BasicTeX` install or
|
||||
allowlist the domain. `tectonic` was uninstalled again. If this is
|
||||
revisited later, either option is viable — see git/workload history for
|
||||
2026-07-14 for the tradeoffs discussed.
|
||||
|
||||
## Font install (one-time, already done as of 2026-07-14)
|
||||
|
||||
Open Sans / Open Sans Light aren't system fonts on macOS by default. They've
|
||||
already been installed to `~/Library/Fonts/` from `assets/fonts/` so Pages/
|
||||
Word render them correctly instead of falling back to a serif substitute.
|
||||
If this is ever run on a different machine, install them first:
|
||||
|
||||
```bash
|
||||
cp .claude/skills/clouddrift-docx/assets/fonts/*.ttf ~/Library/Fonts/
|
||||
```
|
||||
|
||||
(Open Sans is SIL Open Font License — freely redistributable. These exact
|
||||
files came from the Cloud Drift case-study docx in `tmp/`.)
|
||||
|
||||
## Regenerating the template
|
||||
|
||||
Only needed if the brand changes. From a scratch directory:
|
||||
|
||||
```bash
|
||||
pandoc -o pandoc-default-reference.docx --print-default-data-file reference.docx
|
||||
cp <skill>/assets/fonts/*.ttf ./fonts/
|
||||
cp <skill>/assets/clouddrift-logo.png ./clouddrift-logo.png
|
||||
python3 <skill>/scripts/build_reference.py # edit colors/fonts/sizes at the top first if rebranding
|
||||
python3 <skill>/scripts/embed_fonts.py clouddrift-reference.docx
|
||||
cp clouddrift-reference.docx <skill>/assets/clouddrift-reference.docx
|
||||
```
|
||||
|
||||
Then sanity-check visually: convert a test markdown file and render page 1
|
||||
with `qlmanage -t -s 1600 -o <dir> file.pdf` (no poppler/pdftoppm needed) —
|
||||
or install `poppler` (`brew install poppler`) for `pdftoppm` to check
|
||||
arbitrary pages of a multi-page doc, which is what caught the page-break
|
||||
issue below.
|
||||
|
||||
## Known fixes
|
||||
|
||||
- **2026-07-14 — empty page before large tables.** Pandoc's default
|
||||
reference doc sets `keepNext`/`keepLines` on every Heading style
|
||||
(standard Word behavior: never strand a heading alone at the bottom of a
|
||||
page). Under Pages specifically, this backfired when a heading was
|
||||
immediately followed by a large table: the heading got stranded alone on
|
||||
a page and the *entire* table got pushed to the next page, leaving a
|
||||
near-empty page in between. `build_reference.py` now explicitly strips
|
||||
`keepNext`/`keepLines` from Heading 1–9 (see `disable_keep_with_next`) so
|
||||
pagination flows naturally — worst case a heading ends up as the last
|
||||
line on a page, which is a far smaller cosmetic cost than a blank page.
|
||||
If a similar gap ever reappears with some other block type, check the
|
||||
relevant style's `pPr` for `keepNext`/`keepLines`/`pageBreakBefore` first.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Bullet markers use the default (black) bullet glyph, not the Fire-Opal-red
|
||||
bullet dot seen in the case study — pandoc generates its own numbering
|
||||
definitions per document rather than inheriting the reference doc's, so
|
||||
this isn't controllable through the reference-doc mechanism alone.
|
||||
- PDF export is macOS/Pages-only; there is no cross-platform fallback in
|
||||
this environment.
|
||||
BIN
.agents/skills/clouddrift-docx/assets/clouddrift-logo.png
Normal file
BIN
.agents/skills/clouddrift-docx/assets/clouddrift-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
.agents/skills/clouddrift-docx/assets/clouddrift-reference.docx
Normal file
BIN
.agents/skills/clouddrift-docx/assets/clouddrift-reference.docx
Normal file
Binary file not shown.
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-bold.ttf
Normal file
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-bold.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-italic.ttf
Normal file
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-italic.ttf
Normal file
Binary file not shown.
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-regular.ttf
Normal file
BIN
.agents/skills/clouddrift-docx/assets/fonts/OpenSans-regular.ttf
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
250
.agents/skills/clouddrift-docx/scripts/build_reference.py
Normal file
250
.agents/skills/clouddrift-docx/scripts/build_reference.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build the Cloud Drift pandoc reference.docx from pandoc's default reference doc."""
|
||||
import copy
|
||||
from docx import Document
|
||||
from docx.shared import Pt, Inches, RGBColor, Emu
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
from docx.oxml import OxmlElement
|
||||
|
||||
SRC = "pandoc-default-reference.docx"
|
||||
OUT = "clouddrift-reference.docx"
|
||||
LOGO = "clouddrift-logo.png"
|
||||
|
||||
FIRE_OPAL = RGBColor(0xE4, 0x52, 0x49)
|
||||
WHITE_COFFEE = RGBColor(0xE8, 0xDC, 0xD0)
|
||||
RAISIN = RGBColor(0x25, 0x25, 0x25)
|
||||
MUTED = RGBColor(0x59, 0x59, 0x59)
|
||||
|
||||
LIGHT = "Open Sans Light"
|
||||
REGULAR = "Open Sans"
|
||||
|
||||
|
||||
def strip_theme_attrs(rpr_or_font_element):
|
||||
"""Remove theme-based color/font attrs so explicit values always win."""
|
||||
if rpr_or_font_element is None:
|
||||
return
|
||||
for tag in ("color",):
|
||||
el = rpr_or_font_element.find(qn(f"w:{tag}"))
|
||||
if el is not None:
|
||||
for attr in ("themeColor", "themeTint", "themeShade"):
|
||||
if el.get(qn(f"w:{attr}")) is not None:
|
||||
del el.attrib[qn(f"w:{attr}")]
|
||||
rfonts = rpr_or_font_element.find(qn("w:rFonts"))
|
||||
if rfonts is not None:
|
||||
for attr in ("asciiTheme", "hAnsiTheme", "eastAsiaTheme", "cstheme"):
|
||||
if rfonts.get(qn(f"w:{attr}")) is not None:
|
||||
del rfonts.attrib[qn(f"w:{attr}")]
|
||||
|
||||
|
||||
def set_style(doc, name, font_name=None, size=None, color=None, bold=None, italic=None):
|
||||
style = doc.styles[name]
|
||||
f = style.font
|
||||
if font_name:
|
||||
f.name = font_name
|
||||
rpr = style.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = OxmlElement("w:rFonts")
|
||||
rpr.append(rfonts)
|
||||
rfonts.set(qn("w:ascii"), font_name)
|
||||
rfonts.set(qn("w:hAnsi"), font_name)
|
||||
rfonts.set(qn("w:cs"), font_name)
|
||||
rfonts.set(qn("w:eastAsia"), font_name)
|
||||
if size:
|
||||
f.size = Pt(size)
|
||||
if color:
|
||||
f.color.rgb = color
|
||||
if bold is not None:
|
||||
f.bold = bold
|
||||
if italic is not None:
|
||||
f.italic = italic
|
||||
strip_theme_attrs(style.element.get_or_add_rPr())
|
||||
|
||||
|
||||
def disable_keep_with_next(doc, names):
|
||||
"""Remove keepNext/keepLines from these styles' pPr.
|
||||
|
||||
Pandoc's default reference doc sets keepNext+keepLines on every Heading
|
||||
style (standard Word behavior: never orphan a heading alone at the
|
||||
bottom of a page). But when the very next block is a large table, this
|
||||
can backfire badly under some renderers (observed in Pages): the
|
||||
heading gets stranded alone on a page and the entire table is pushed to
|
||||
the following page, leaving a mostly-blank page in between. Dropping
|
||||
keepNext/keepLines lets pagination flow naturally instead - worst case
|
||||
a heading ends up as the last line on a page, which is a far smaller
|
||||
cosmetic cost than a near-empty page.
|
||||
"""
|
||||
for name in names:
|
||||
style = doc.styles[name]
|
||||
pPr = style.element.get_or_add_pPr()
|
||||
for tag in ("w:keepNext", "w:keepLines"):
|
||||
el = pPr.find(qn(tag))
|
||||
if el is not None:
|
||||
pPr.remove(el)
|
||||
|
||||
|
||||
def add_field(paragraph, field_code):
|
||||
run = paragraph.add_run()
|
||||
r = run._r
|
||||
fld_begin = OxmlElement("w:fldChar")
|
||||
fld_begin.set(qn("w:fldCharType"), "begin")
|
||||
instr = OxmlElement("w:instrText")
|
||||
instr.set(qn("xml:space"), "preserve")
|
||||
instr.text = f" {field_code} "
|
||||
fld_sep = OxmlElement("w:fldChar")
|
||||
fld_sep.set(qn("w:fldCharType"), "separate")
|
||||
fld_end = OxmlElement("w:fldChar")
|
||||
fld_end.set(qn("w:fldCharType"), "end")
|
||||
r.append(fld_begin)
|
||||
r2 = paragraph.add_run()._r
|
||||
r2.append(instr)
|
||||
r3 = paragraph.add_run()._r
|
||||
r3.append(fld_sep)
|
||||
r4 = paragraph.add_run()._r
|
||||
r4.append(fld_end)
|
||||
|
||||
|
||||
def main():
|
||||
doc = Document(SRC)
|
||||
|
||||
# --- Base body text ---
|
||||
set_style(doc, "Normal", LIGHT, 11, RAISIN)
|
||||
set_style(doc, "Body Text", LIGHT, 11, RAISIN)
|
||||
set_style(doc, "Compact", LIGHT, 11, RAISIN)
|
||||
set_style(doc, "First Paragraph", LIGHT, 11, RAISIN)
|
||||
set_style(doc, "Default Paragraph Font", LIGHT, 11, RAISIN)
|
||||
|
||||
# --- Title / subtitle ---
|
||||
set_style(doc, "Title", LIGHT, 30, FIRE_OPAL, bold=False)
|
||||
doc.styles["Title"].paragraph_format.space_after = Pt(4)
|
||||
set_style(doc, "Subtitle", LIGHT, 14, MUTED, bold=False, italic=False)
|
||||
|
||||
# --- Headings ---
|
||||
set_style(doc, "Heading 1", LIGHT, 22, RAISIN, bold=False)
|
||||
doc.styles["Heading 1"].paragraph_format.space_before = Pt(20)
|
||||
doc.styles["Heading 1"].paragraph_format.space_after = Pt(8)
|
||||
|
||||
set_style(doc, "Heading 2", REGULAR, 16, FIRE_OPAL, bold=True)
|
||||
doc.styles["Heading 2"].paragraph_format.space_before = Pt(16)
|
||||
doc.styles["Heading 2"].paragraph_format.space_after = Pt(6)
|
||||
|
||||
set_style(doc, "Heading 3", REGULAR, 13, RAISIN, bold=True)
|
||||
doc.styles["Heading 3"].paragraph_format.space_before = Pt(12)
|
||||
|
||||
for lvl, sz in ((4, 11.5), (5, 11), (6, 11)):
|
||||
name = f"Heading {lvl}"
|
||||
set_style(doc, name, REGULAR, sz, MUTED, bold=True, italic=(lvl == 6))
|
||||
|
||||
# Avoid huge empty-page gaps when a heading is immediately followed by
|
||||
# a large table (see disable_keep_with_next docstring).
|
||||
disable_keep_with_next(doc, [f"Heading {n}" for n in range(1, 10)])
|
||||
|
||||
# --- Quotes / block text ---
|
||||
set_style(doc, "Block Text", LIGHT, 11, MUTED, italic=True)
|
||||
bt_pPr = doc.styles["Block Text"].element.get_or_add_pPr()
|
||||
pbdr = OxmlElement("w:pBdr")
|
||||
left = OxmlElement("w:left")
|
||||
left.set(qn("w:val"), "single")
|
||||
left.set(qn("w:sz"), "18")
|
||||
left.set(qn("w:space"), "8")
|
||||
left.set(qn("w:color"), "E45249")
|
||||
pbdr.append(left)
|
||||
bt_pPr.append(pbdr)
|
||||
|
||||
# --- Hyperlinks ---
|
||||
set_style(doc, "Hyperlink", LIGHT, None, FIRE_OPAL)
|
||||
hl_rpr = doc.styles["Hyperlink"].element.get_or_add_rPr()
|
||||
u = OxmlElement("w:u")
|
||||
u.set(qn("w:val"), "single")
|
||||
hl_rpr.append(u)
|
||||
|
||||
# --- Table: shaded header row, light borders ---
|
||||
table_style = doc.styles["Table"]
|
||||
tbl_pr = table_style.element.find(qn("w:tblPr"))
|
||||
if tbl_pr is None:
|
||||
tbl_pr = OxmlElement("w:tblPr")
|
||||
table_style.element.append(tbl_pr)
|
||||
borders = OxmlElement("w:tblBorders")
|
||||
for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||||
el = OxmlElement(f"w:{edge}")
|
||||
el.set(qn("w:val"), "single")
|
||||
el.set(qn("w:sz"), "4")
|
||||
el.set(qn("w:space"), "0")
|
||||
el.set(qn("w:color"), "E8DCD0")
|
||||
borders.append(el)
|
||||
tbl_pr.append(borders)
|
||||
|
||||
style_pr = table_style.element.find(qn("w:tblStylePr"))
|
||||
if style_pr is None:
|
||||
style_pr = OxmlElement("w:tblStylePr")
|
||||
style_pr.set(qn("w:type"), "firstRow")
|
||||
table_style.element.append(style_pr)
|
||||
tc_pr = style_pr.find(qn("w:tcPr"))
|
||||
if tc_pr is None:
|
||||
tc_pr = OxmlElement("w:tcPr")
|
||||
style_pr.append(tc_pr)
|
||||
shd = OxmlElement("w:shd")
|
||||
shd.set(qn("w:val"), "clear")
|
||||
shd.set(qn("w:color"), "auto")
|
||||
shd.set(qn("w:fill"), "E8DCD0")
|
||||
tc_pr.append(shd)
|
||||
rpr_fr = style_pr.find(qn("w:rPr"))
|
||||
if rpr_fr is None:
|
||||
rpr_fr = OxmlElement("w:rPr")
|
||||
style_pr.append(rpr_fr)
|
||||
b_el = OxmlElement("w:b")
|
||||
rpr_fr.append(b_el)
|
||||
color_el = OxmlElement("w:color")
|
||||
color_el.set(qn("w:val"), "252525")
|
||||
rpr_fr.append(color_el)
|
||||
|
||||
# --- Verbatim / code ---
|
||||
try:
|
||||
set_style(doc, "Verbatim Char", None, 10, RAISIN)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# --- Page setup: A4, 1 inch margins ---
|
||||
section = doc.sections[0]
|
||||
section.page_width = Inches(8.27)
|
||||
section.page_height = Inches(11.69)
|
||||
section.left_margin = Inches(1)
|
||||
section.right_margin = Inches(1)
|
||||
section.top_margin = Inches(1)
|
||||
section.bottom_margin = Inches(1)
|
||||
section.header_distance = Inches(0.4)
|
||||
section.footer_distance = Inches(0.4)
|
||||
|
||||
# --- Header: Cloud Drift logo ---
|
||||
header = section.header
|
||||
header.is_linked_to_previous = False
|
||||
hp = header.paragraphs[0]
|
||||
hp.text = ""
|
||||
hp.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
run = hp.add_run()
|
||||
run.add_picture(LOGO, width=Inches(0.85))
|
||||
|
||||
# --- Footer: page number, right aligned, muted ---
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
fp = footer.paragraphs[0]
|
||||
fp.text = ""
|
||||
fp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
run = fp.add_run("Page ")
|
||||
run.font.name = REGULAR
|
||||
run.font.size = Pt(9)
|
||||
run.font.color.rgb = MUTED
|
||||
add_field(fp, "PAGE")
|
||||
run2 = fp.add_run(" of ")
|
||||
run2.font.name = REGULAR
|
||||
run2.font.size = Pt(9)
|
||||
run2.font.color.rgb = MUTED
|
||||
add_field(fp, "NUMPAGES")
|
||||
|
||||
doc.save(OUT)
|
||||
print("Saved", OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
.agents/skills/clouddrift-docx/scripts/convert.py
Normal file
112
.agents/skills/clouddrift-docx/scripts/convert.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Convert a Markdown file into a Cloud Drift branded .docx and/or .pdf.
|
||||
|
||||
Usage:
|
||||
python3 convert.py INPUT.md [--format docx|pdf|both] [--output PATH]
|
||||
[--reference-doc PATH]
|
||||
|
||||
docx is produced by pandoc using the bundled Cloud Drift reference.docx
|
||||
(fonts, colors, logo header, footer pagination). pdf is produced by asking
|
||||
macOS Pages to open that docx and export it to PDF, so the PDF is pixel-for-
|
||||
pixel the same branded layout, not a second independent template.
|
||||
"""
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parent.parent
|
||||
REFERENCE_DOC = SKILL_DIR / "assets" / "clouddrift-reference.docx"
|
||||
|
||||
|
||||
def convert_to_docx(input_md: Path, output_docx: Path, reference_doc: Path):
|
||||
if shutil.which("pandoc") is None:
|
||||
sys.exit("pandoc is not installed or not on PATH.")
|
||||
cmd = [
|
||||
"pandoc",
|
||||
str(input_md),
|
||||
"-o",
|
||||
str(output_docx),
|
||||
f"--reference-doc={reference_doc}",
|
||||
"--standalone",
|
||||
]
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Wrote {output_docx}")
|
||||
|
||||
|
||||
def convert_docx_to_pdf(input_docx: Path, output_pdf: Path):
|
||||
"""Use macOS Pages (via AppleScript) to export the docx to PDF, preserving
|
||||
the exact branded layout produced by the reference.docx styles."""
|
||||
if sys.platform != "darwin":
|
||||
sys.exit("PDF export currently requires macOS (uses Pages via AppleScript).")
|
||||
script = f'''
|
||||
try
|
||||
tell application "Pages"
|
||||
set theDoc to open POSIX file "{input_docx.resolve()}"
|
||||
delay 2
|
||||
export theDoc to POSIX file "{output_pdf.resolve()}" as PDF
|
||||
close theDoc saving no
|
||||
end tell
|
||||
return "SUCCESS"
|
||||
on error errMsg number errNum
|
||||
return "ERROR: " & errMsg & " (" & errNum & ")"
|
||||
end try
|
||||
'''
|
||||
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
|
||||
out = result.stdout.strip()
|
||||
if out != "SUCCESS":
|
||||
sys.exit(
|
||||
"Pages PDF export failed: "
|
||||
f"{out or result.stderr.strip()}\n"
|
||||
"If this is the first run, macOS may need you to grant automation "
|
||||
"permission for controlling Pages (System Settings > Privacy & "
|
||||
"Security > Automation), or Pages may need a moment after "
|
||||
"launching — try again."
|
||||
)
|
||||
print(f"Wrote {output_pdf}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path, help="Input Markdown file")
|
||||
parser.add_argument(
|
||||
"--format", choices=["docx", "pdf", "both"], default="docx",
|
||||
help="Output format (default: docx)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=None,
|
||||
help="Output path (without extension needed for --format both). "
|
||||
"Defaults to the input filename next to the input file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--reference-doc", type=Path, default=REFERENCE_DOC,
|
||||
help="Override the Cloud Drift reference.docx template",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.input.exists():
|
||||
sys.exit(f"Input file not found: {args.input}")
|
||||
if not args.reference_doc.exists():
|
||||
sys.exit(f"Reference doc not found: {args.reference_doc}")
|
||||
|
||||
stem = args.output if args.output else args.input.with_suffix("")
|
||||
docx_path = stem.with_suffix(".docx")
|
||||
pdf_path = stem.with_suffix(".pdf")
|
||||
|
||||
if args.format in ("docx", "both"):
|
||||
convert_to_docx(args.input, docx_path, args.reference_doc)
|
||||
|
||||
if args.format in ("pdf", "both"):
|
||||
if not docx_path.exists():
|
||||
convert_to_docx(args.input, docx_path, args.reference_doc)
|
||||
convert_docx_to_pdf(docx_path, pdf_path)
|
||||
if args.format == "pdf" and docx_path.exists() and args.output is None:
|
||||
# pdf-only was requested and we only made the docx as an
|
||||
# intermediate step; clean it up unless the caller named an
|
||||
# explicit --output (in which case leave both, they may want it).
|
||||
docx_path.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
127
.agents/skills/clouddrift-docx/scripts/embed_fonts.py
Normal file
127
.agents/skills/clouddrift-docx/scripts/embed_fonts.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Embed Open Sans / Open Sans Light TTFs into a docx so it renders correctly
|
||||
even on machines that don't have the fonts installed. Mirrors the (unobfuscated)
|
||||
embedding scheme found in the Cloud Drift case-study docx: fontKey all-zeros,
|
||||
plain .ttf parts referenced directly."""
|
||||
import shutil
|
||||
import zipfile
|
||||
import re
|
||||
import sys
|
||||
|
||||
DOCX = sys.argv[1] if len(sys.argv) > 1 else "clouddrift-reference.docx"
|
||||
FONT_DIR = "fonts"
|
||||
|
||||
FONTS = {
|
||||
"Open Sans Light": {
|
||||
"regular": "OpenSansLight-regular.ttf",
|
||||
"bold": "OpenSansLight-bold.ttf",
|
||||
"italic": "OpenSansLight-italic.ttf",
|
||||
"boldItalic": "OpenSansLight-boldItalic.ttf",
|
||||
},
|
||||
"Open Sans": {
|
||||
"regular": "OpenSans-regular.ttf",
|
||||
"bold": "OpenSans-bold.ttf",
|
||||
"italic": "OpenSans-italic.ttf",
|
||||
"boldItalic": "OpenSans-boldItalic.ttf",
|
||||
},
|
||||
}
|
||||
|
||||
NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
FONT_REL_TYPE = f"{NS_R}/font".replace(NS_R, "http://schemas.openxmlformats.org/officeDocument/2006/relationships") + "/font"
|
||||
FONT_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font"
|
||||
|
||||
|
||||
def build_font_table_and_rels():
|
||||
rid = 1
|
||||
font_entries = []
|
||||
rels = []
|
||||
file_map = {}
|
||||
for name, variants in FONTS.items():
|
||||
embeds = []
|
||||
for kind, fname in variants.items():
|
||||
tag = {
|
||||
"regular": "w:embedRegular",
|
||||
"bold": "w:embedBold",
|
||||
"italic": "w:embedItalic",
|
||||
"boldItalic": "w:embedBoldItalic",
|
||||
}[kind]
|
||||
rId = f"rIdFont{rid}"
|
||||
embeds.append(
|
||||
f'<{tag} r:id="{rId}" w:fontKey="{{00000000-0000-0000-0000-000000000000}}" w:subsetted="0"/>'
|
||||
)
|
||||
rels.append(
|
||||
f'<Relationship Id="{rId}" Type="{FONT_REL_TYPE}" Target="fonts/{fname}"/>'
|
||||
)
|
||||
file_map[rId] = fname
|
||||
rid += 1
|
||||
font_entries.append(f'<w:font w:name="{name}">{"".join(embeds)}</w:font>')
|
||||
|
||||
font_table_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:fonts xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" '
|
||||
'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
+ "".join(font_entries)
|
||||
+ "</w:fonts>"
|
||||
)
|
||||
rels_xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
+ "".join(rels)
|
||||
+ "</Relationships>"
|
||||
)
|
||||
return font_table_xml, rels_xml, file_map
|
||||
|
||||
|
||||
def patch_settings(xml_text):
|
||||
if "embedTrueTypeFonts" in xml_text:
|
||||
return xml_text
|
||||
return re.sub(
|
||||
r"(<w:settings[^>]*>)",
|
||||
r'\1<w:embedTrueTypeFonts w:val="1"/>',
|
||||
xml_text,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
def patch_content_types(xml_text):
|
||||
if 'Extension="ttf"' in xml_text:
|
||||
return xml_text
|
||||
return xml_text.replace(
|
||||
"<Types ",
|
||||
"<Types ",
|
||||
).replace(
|
||||
'<Default Extension="rels"',
|
||||
'<Default Extension="ttf" ContentType="application/x-font-ttf"/><Default Extension="rels"',
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
font_table_xml, rels_xml, file_map = build_font_table_and_rels()
|
||||
|
||||
tmp = DOCX + ".tmp"
|
||||
with zipfile.ZipFile(DOCX, "r") as zin:
|
||||
names = zin.namelist()
|
||||
with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
for item in zin.infolist():
|
||||
data = zin.read(item.filename)
|
||||
if item.filename == "word/fontTable.xml":
|
||||
data = font_table_xml.encode("utf-8")
|
||||
elif item.filename == "word/settings.xml":
|
||||
data = patch_settings(data.decode("utf-8")).encode("utf-8")
|
||||
elif item.filename == "[Content_Types].xml":
|
||||
data = patch_content_types(data.decode("utf-8")).encode("utf-8")
|
||||
zout.writestr(item, data)
|
||||
|
||||
if "word/_rels/fontTable.xml.rels" not in names:
|
||||
zout.writestr("word/_rels/fontTable.xml.rels", rels_xml)
|
||||
|
||||
for rId, fname in file_map.items():
|
||||
with open(f"{FONT_DIR}/{fname}", "rb") as f:
|
||||
zout.writestr(f"word/fonts/{fname}", f.read())
|
||||
|
||||
shutil.move(tmp, DOCX)
|
||||
print("Embedded fonts into", DOCX)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
84
.agents/skills/extract-transcript/SKILL.md
Normal file
84
.agents/skills/extract-transcript/SKILL.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
name: extract-transcript
|
||||
description: Extract the meeting transcript from a saved Fireflies.ai (or similar) .mhtml page capture into a Markdown file. Use when the user asks to "extract the transcript", "convert this mhtml to a transcript", "get the transcript out of this mhtml file", or provides a `.mhtml` meeting recording file and wants the transcript pulled out. Takes one filename argument.
|
||||
---
|
||||
|
||||
# Extract transcript skill
|
||||
|
||||
## Purpose
|
||||
|
||||
Fireflies.ai (and similar tools) render meeting transcripts client-side,
|
||||
behind login - `WebFetch` and plain `curl`/headless-browser scraping only
|
||||
see the app shell, never the transcript. The reliable workaround is for the
|
||||
user to save the fully-rendered meeting page as a browser `.mhtml` page
|
||||
capture (File > Save Page As > Webpage, Single File, or the browser's
|
||||
"Save as MHTML" option). This skill turns that `.mhtml` capture into a
|
||||
clean Markdown transcript, deterministically - no model reasoning needed.
|
||||
|
||||
## How to run this skill
|
||||
|
||||
Run the bundled script with Bash, passing the `.mhtml` file path as the
|
||||
only argument:
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/extract_transcript.py" "<path/to/file.mhtml>"
|
||||
```
|
||||
|
||||
Resolve `<skill-dir>` to this skill's own directory (the directory
|
||||
containing this SKILL.md) and `<path/to/file.mhtml>` to the file the user
|
||||
gave you - accept it whether they pasted an absolute path, a relative
|
||||
path, or just referenced an open/attached file.
|
||||
|
||||
The script:
|
||||
- Parses the `.mhtml` as a MIME multipart message (Python's `email`
|
||||
module) and finds the rendered HTML part(s).
|
||||
- Locates the transcript panel (`id` ending in `content-transcript`, or
|
||||
falls back to the largest `ScrollArea-styled__Root` element) and walks
|
||||
its structured DOM - per-paragraph speaker name, timestamp, and sentence
|
||||
text - rather than flattening all text, which would lose speaker
|
||||
boundaries.
|
||||
- Extracts the page `<title>` and a best-effort meeting date/time from the
|
||||
page text.
|
||||
- Writes the output next to the input file, same directory and basename,
|
||||
with a `.md` extension (e.g. `Team sync.mhtml` -> `Team sync.md`),
|
||||
overwriting any existing file at that path.
|
||||
|
||||
If `beautifulsoup4` isn't installed, the script exits with the exact
|
||||
`pip3 install beautifulsoup4` command to run - run it, then retry.
|
||||
|
||||
## After running
|
||||
|
||||
Report the output path and the number of transcript lines extracted (the
|
||||
script prints both). Do not summarize or otherwise process the transcript
|
||||
unless the user separately asks for that (e.g. ingesting it into the
|
||||
wiki) - this skill's job ends at producing the `.md` file.
|
||||
|
||||
The script also detects and reports two common capture mistakes rather
|
||||
than silently producing bad output:
|
||||
|
||||
- **Wrong tab active:** if the page was saved while a tab other than
|
||||
Transcript was open (commonly Notes), Fireflies never mounted the
|
||||
transcript panel into the DOM at all. The script detects this and tells
|
||||
the user to reopen the meeting, click Transcript, and re-save.
|
||||
- **Incomplete scroll:** Fireflies virtualizes the transcript list, so if
|
||||
the user didn't scroll all the way through it before saving, only the
|
||||
visible portion is captured and the rest is silently missing (not a
|
||||
quiet stretch of the meeting). The script still writes the `.md` file in
|
||||
this case but prints a warning to stderr for any gap of 90+ seconds
|
||||
between consecutive lines, with the exact timestamp range of each gap,
|
||||
and the same "scroll to the end, then re-save" guidance. Pass this
|
||||
warning on to the user rather than treating a successful "Wrote N lines"
|
||||
message as automatically complete.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Built against Fireflies.ai's current page structure (styled-components
|
||||
class names change on redeploys, so a Fireflies UI change could break
|
||||
the selectors - if extraction fails, inspect the `.mhtml`'s HTML part
|
||||
for the new transcript container structure and update
|
||||
`scripts/extract_transcript.py` accordingly).
|
||||
- Only captures speakers and sentences visible in the saved page. If the
|
||||
transcript panel wasn't fully scrolled/loaded before saving, only the
|
||||
loaded portion will be present.
|
||||
- Attendee list is inferred purely from who has transcript lines - silent
|
||||
attendees who never spoke won't appear.
|
||||
233
.agents/skills/extract-transcript/scripts/extract_transcript.py
Normal file
233
.agents/skills/extract-transcript/scripts/extract_transcript.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Extract a Fireflies.ai (or similar) meeting transcript from a saved .mhtml page.
|
||||
|
||||
Usage:
|
||||
python3 extract_transcript.py <path/to/page.mhtml>
|
||||
|
||||
Writes <path/to/page>.md next to the input file (same directory, same
|
||||
basename, .md extension), containing the meeting title/date if found and
|
||||
the speaker-by-speaker transcript with timestamps.
|
||||
"""
|
||||
import email
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Missing dependency 'beautifulsoup4'. Install it with:\n"
|
||||
" pip3 install beautifulsoup4"
|
||||
)
|
||||
|
||||
MONTHS = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec"
|
||||
DATE_RE = re.compile(rf"({MONTHS})[a-z]* \d{{1,2}},? \d{{4}}(?:, \d{{1,2}}:\d{{2}} ?[AP]M)?")
|
||||
|
||||
# Fireflies virtualizes the transcript list - if the user didn't scroll
|
||||
# through the whole thing before saving the page, only the visible portion
|
||||
# ends up in the DOM/mhtml, leaving silent gaps. A gap this long between
|
||||
# consecutive timestamps is a strong signal that's what happened.
|
||||
GAP_WARNING_SECONDS = 90
|
||||
|
||||
|
||||
def parse_timestamp(ts):
|
||||
if not ts:
|
||||
return None
|
||||
parts = ts.split(":")
|
||||
try:
|
||||
parts = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
seconds = 0
|
||||
for p in parts:
|
||||
seconds = seconds * 60 + p
|
||||
return seconds
|
||||
|
||||
|
||||
def find_large_gaps(lines):
|
||||
gaps = []
|
||||
prev_ts = None
|
||||
prev_idx = None
|
||||
for idx, (ts, _, _) in enumerate(lines):
|
||||
secs = parse_timestamp(ts)
|
||||
if secs is None:
|
||||
continue
|
||||
if prev_ts is not None and secs - prev_ts >= GAP_WARNING_SECONDS:
|
||||
gaps.append((prev_idx, idx, secs - prev_ts))
|
||||
prev_ts, prev_idx = secs, idx
|
||||
return gaps
|
||||
|
||||
|
||||
def load_html_parts(mhtml_path):
|
||||
with open(mhtml_path, "rb") as f:
|
||||
msg = email.message_from_binary_file(f)
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() != "text/html":
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
continue
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
yield payload.decode(charset, errors="replace")
|
||||
|
||||
|
||||
def find_transcript_container(html):
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
container = soup.find(id=lambda i: i and i.endswith("content-transcript"))
|
||||
if container is not None:
|
||||
return soup, container
|
||||
# Fallback: some captures use the ScrollArea class directly without the
|
||||
# radix id being present in this particular MIME part.
|
||||
candidates = soup.find_all(
|
||||
class_=lambda c: c and any("ScrollArea-styled__Root" in x for x in (c if isinstance(c, list) else [c]))
|
||||
)
|
||||
if candidates:
|
||||
# The transcript panel is reliably the largest ScrollArea on the page.
|
||||
best = max(candidates, key=lambda el: len(el.get_text()))
|
||||
if len(best.get_text(strip=True)) > 0:
|
||||
return soup, best
|
||||
return soup, None
|
||||
|
||||
|
||||
def extract_paragraphs(container):
|
||||
paragraphs = container.find_all(
|
||||
"div", id=lambda i: i and i.startswith("transcript-paragraph-")
|
||||
)
|
||||
lines = []
|
||||
last_name = None
|
||||
for p in paragraphs:
|
||||
name_span = p.find("span", class_="name")
|
||||
name = name_span.get_text(strip=True) if name_span else (last_name or "Unknown speaker")
|
||||
last_name = name
|
||||
ts_span = p.find("span", attrs={"text-decoration": "underline"})
|
||||
ts = ts_span.get_text(strip=True) if ts_span else ""
|
||||
content_div = p.find("div", class_=lambda c: c and "ContentPost-styled__Content" in c)
|
||||
text = content_div.get_text(" ", strip=True) if content_div else p.get_text(" ", strip=True)
|
||||
if not text:
|
||||
continue
|
||||
lines.append((ts, name, text))
|
||||
return lines
|
||||
|
||||
|
||||
def find_inactive_transcript_tab(html_parts):
|
||||
"""Detect the common failure case: the page was saved with a different
|
||||
tab (usually Notes) active, so Fireflies never mounted the transcript
|
||||
panel into the DOM at all - there's nothing to extract, not a selector
|
||||
mismatch."""
|
||||
for html in html_parts:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
tab = soup.find(
|
||||
attrs={"role": "tab"},
|
||||
id=lambda i: i and i.endswith("trigger-transcript"),
|
||||
)
|
||||
if tab is not None:
|
||||
active_tab = soup.find(attrs={"role": "tab", "data-state": "active"})
|
||||
active_name = active_tab.get_text(strip=True) if active_tab else "another tab"
|
||||
return tab.get("data-state") != "active", active_name
|
||||
return False, None
|
||||
|
||||
|
||||
def extract_title_and_date(html_parts):
|
||||
title = None
|
||||
date = None
|
||||
for html in html_parts:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
if title is None and soup.title and soup.title.get_text(strip=True):
|
||||
title = soup.title.get_text(strip=True)
|
||||
if date is None:
|
||||
m = DATE_RE.search(soup.get_text(" ", strip=True))
|
||||
if m:
|
||||
date = m.group(0)
|
||||
if title and date:
|
||||
break
|
||||
return title, date
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit(f"Usage: {sys.argv[0]} <path/to/page.mhtml>")
|
||||
|
||||
src = Path(sys.argv[1]).expanduser()
|
||||
if not src.is_file():
|
||||
sys.exit(f"File not found: {src}")
|
||||
|
||||
html_parts = list(load_html_parts(src))
|
||||
if not html_parts:
|
||||
sys.exit("No text/html part found in this .mhtml file - is it a valid MIME HTML capture?")
|
||||
|
||||
lines = []
|
||||
for html in html_parts:
|
||||
_, container = find_transcript_container(html)
|
||||
if container is None:
|
||||
continue
|
||||
lines = extract_paragraphs(container)
|
||||
if lines:
|
||||
break
|
||||
|
||||
if not lines:
|
||||
tab_inactive, active_name = find_inactive_transcript_tab(html_parts)
|
||||
if tab_inactive:
|
||||
sys.exit(
|
||||
f"No transcript found - this page was saved with the "
|
||||
f"'{active_name}' tab open, not 'Transcript'. Fireflies only "
|
||||
f"renders the active tab's content into the page, so the "
|
||||
f"transcript panel isn't in this file at all. Reopen the "
|
||||
f"meeting, click the 'Transcript' tab, wait for it to load "
|
||||
f"and scroll to the end (so the full transcript renders), "
|
||||
f"then re-save the page and try again."
|
||||
)
|
||||
sys.exit(
|
||||
"Could not find a transcript panel in this file. This script targets "
|
||||
"Fireflies.ai-style pages (a ScrollArea containing "
|
||||
"'#*-content-transcript'). If the page structure differs, the "
|
||||
"selectors in extract_transcript.py need updating."
|
||||
)
|
||||
|
||||
title, date = extract_title_and_date(html_parts)
|
||||
speakers = sorted(set(name for _, name, _ in lines))
|
||||
|
||||
out_path = src.with_suffix(".md")
|
||||
|
||||
body = []
|
||||
body.append(f"# {title or src.stem}")
|
||||
body.append("")
|
||||
if date:
|
||||
body.append(f"- **Date:** {date}")
|
||||
body.append(f"- **Attendees (speakers detected):** {', '.join(speakers)}")
|
||||
body.append(f"- **Source:** `{src.name}`")
|
||||
body.append("")
|
||||
body.append("## Transcript")
|
||||
body.append("")
|
||||
# Blank line between entries (not a single "\n") so each timestamp
|
||||
# starts its own paragraph in Markdown preview - a lone newline is a
|
||||
# soft break that most renderers collapse into one running paragraph.
|
||||
entries = []
|
||||
for ts, name, text in lines:
|
||||
prefix = f"[{ts}] " if ts else ""
|
||||
entries.append(f"{prefix}{name}: {text}")
|
||||
body.append("\n\n".join(entries))
|
||||
|
||||
out_path.write_text("\n".join(body) + "\n", encoding="utf-8")
|
||||
print(f"Wrote {len(lines)} transcript lines to {out_path}")
|
||||
|
||||
gaps = find_large_gaps(lines)
|
||||
if gaps:
|
||||
print(
|
||||
f"WARNING: {len(gaps)} gap(s) of {GAP_WARNING_SECONDS}s or more "
|
||||
f"between consecutive lines - Fireflies virtualizes the transcript "
|
||||
f"list, so this usually means the page was saved before scrolling "
|
||||
f"through the whole transcript, and content in between is simply "
|
||||
f"missing (not silence). Re-open the meeting, scroll the "
|
||||
f"Transcript tab all the way to the end first, then re-save and "
|
||||
f"re-run this script:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for start_idx, end_idx, gap_secs in gaps:
|
||||
start_ts = lines[start_idx][0]
|
||||
end_ts = lines[end_idx][0]
|
||||
print(f" - {start_ts} -> {end_ts} ({gap_secs}s gap)", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
196
.agents/skills/fireflies-relanguage/SKILL.md
Normal file
196
.agents/skills/fireflies-relanguage/SKILL.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
---
|
||||
name: fireflies-relanguage
|
||||
description: Fix a wrong-language Fireflies transcript on a meeting you don't own (can't hit Reprocess yourself) by re-uploading its audio under the correct language via the Fireflies API, recovering real speaker names into the result with transcript-speaker-fill, then deleting the duplicate meeting. Use when the user has a link to a Fireflies recording they aren't the host/owner of, the transcript is in the wrong language, and they want a corrected transcript without waiting for the owner to act or leaving a duplicate meeting behind. Trigger phrases: "I'm not the owner of this meeting", "can't reprocess this transcript", "fix this Fireflies transcript without the owner", "re-transcribe a shared meeting".
|
||||
---
|
||||
|
||||
# Fireflies relanguage skill
|
||||
|
||||
## Purpose
|
||||
|
||||
Fireflies' own "Update Language + Reprocess" fix only works for the meeting's
|
||||
host/owner (see `guide.fireflies.ai` docs). If you were just sent a link to
|
||||
someone else's recording and it came out in the wrong language, you have no
|
||||
UI path to fix it and can't wait for the owner. This skill routes around
|
||||
that: pull the meeting's audio via the API, re-upload it as a **new** meeting
|
||||
under your own account with the correct language forced, recover the
|
||||
original's real speaker names into the new, correctly-transcribed text using
|
||||
the [[transcript-speaker-fill]](../transcript-speaker-fill/SKILL.md) skill,
|
||||
then delete the duplicate meeting the re-upload created.
|
||||
|
||||
## Before you start: real constraints, not hypothetical ones
|
||||
|
||||
**This may simply not be possible on your account.** `audio_url`/`video_url`
|
||||
on a Fireflies transcript require a Pro+ plan, and it is undocumented whether
|
||||
they populate at all for a meeting merely *shared* with you rather than
|
||||
owned by you. [Michał's own wiki notes](/wiki/entities/fireflies-transcript-handling.md)
|
||||
record hitting exactly this wall before (no Pro seat → transcript download
|
||||
tooling broke). Step 1 below (`fetch`) is also the diagnostic: if it prints
|
||||
the "audio_url is null" warning, stop here — this workflow cannot proceed
|
||||
without one of the workarounds it suggests (a Pro+ seat's API key, the actual
|
||||
owner reprocessing it, or you manually downloading the audio via the web UI
|
||||
and hosting it at some public HTTPS URL yourself).
|
||||
|
||||
**Every re-upload consumes real transcription quota/minutes** on the account
|
||||
whose API key you use, same as any other Fireflies upload — this isn't free
|
||||
just because it's automated. **Deleting a transcript is irreversible.** Don't
|
||||
run this on a meeting you actually might need to keep two copies of, and
|
||||
don't skip the confirmation gates below.
|
||||
|
||||
Also worth knowing up front: the Fireflies API has thin rate limits (Free:
|
||||
50 requests/day, Pro: 500/day, Business/Enterprise: 60/min) and
|
||||
`deleteTranscript` specifically is capped at 10/min. This workflow only
|
||||
needs a handful of calls plus some polling, but don't loop the `wait` step
|
||||
aggressively.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `FIREFLIES_API_KEY` set in the environment. Get one from the Fireflies web
|
||||
app under **Settings > API**. The script exits with this exact instruction
|
||||
if it's missing — don't hardcode the key anywhere.
|
||||
- The `transcript-speaker-fill` skill installed alongside this one (it is,
|
||||
in this repo) — this skill hands off the actual name-recovery merge to it
|
||||
rather than duplicating that logic.
|
||||
|
||||
## Workflow
|
||||
|
||||
Resolve `<skill-dir>` to this skill's own directory throughout.
|
||||
|
||||
### 1. Fetch the original (broken-language) transcript
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fireflies_client.py" fetch --ref "<link-or-id-the-user-gave-you>" --out original.md
|
||||
```
|
||||
|
||||
Accepts a bare transcript id or a full share link like
|
||||
`https://app.fireflies.ai/view/Some-Title::abcDEF123`. Writes `original.md`
|
||||
(real speaker names, garbled text — the shape `transcript-speaker-fill`
|
||||
expects for its "broken" input) and `original.md.meta.json` (id, title,
|
||||
`audio_url`, etc., needed by later steps).
|
||||
|
||||
**Check the printed output for the audio_url warning before continuing.** If
|
||||
it's null, stop and follow the guidance it prints instead of proceeding to
|
||||
step 2 — do not attempt step 2 anyway "just to see."
|
||||
|
||||
### 2. Confirm the target language with the user
|
||||
|
||||
Don't guess the correct language from context alone unless it's unambiguous
|
||||
(e.g. the user already told you). Ask if unclear. Use the language code
|
||||
Fireflies expects — check
|
||||
[Learn about Fireflies supported languages](https://guide.fireflies.ai/articles/2973706448-learn-about-fireflies-supported-languages)
|
||||
for the exact code if you're not sure it matches (e.g. `en`, `pl`, `es`).
|
||||
|
||||
### 3. Re-upload the audio under the correct language
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fireflies_client.py" upload --meta original.md.meta.json --language <code>
|
||||
```
|
||||
|
||||
This calls `uploadAudio` with `custom_language` set, tagging the new
|
||||
meeting's title with `[relang:<original-id>]` so it can be found
|
||||
unambiguously afterward (override with `--title` if you want a cleaner
|
||||
name, but then pass that exact same string to step 4). Note the exact title
|
||||
printed — you need it verbatim for the next step.
|
||||
|
||||
### 4. Wait for the re-upload to finish processing
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fireflies_client.py" wait --title "<exact title from step 3>"
|
||||
```
|
||||
|
||||
Polls every 30s (default) up to 15 minutes (default) for a transcript with
|
||||
that exact title to appear with content. Long recordings can take longer —
|
||||
if it times out, just re-run `wait` again rather than assuming failure.
|
||||
Prints the new transcript's `id` once ready.
|
||||
|
||||
### 5. Fetch the new (correct-language) transcript
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fireflies_client.py" fetch --ref "<id from step 4>" --out new.md
|
||||
```
|
||||
|
||||
This one should come back with correct text but generic `Speaker 1`,
|
||||
`Speaker 2`, ... labels (no calendar/roster context on a bare re-upload) —
|
||||
exactly the gap `transcript-speaker-fill` closes.
|
||||
|
||||
### 6. Recover real speaker names
|
||||
|
||||
Hand off to the `transcript-speaker-fill` skill exactly per its own
|
||||
SKILL.md, using `original.md` and `new.md` as the two inputs — dry run
|
||||
first, present the resolution report, get the user's confirmation/manual
|
||||
overrides, then `--apply`. Do not skip its confirmation gate just because
|
||||
you're mid-pipeline; it's exactly as load-bearing here as when invoked
|
||||
standalone.
|
||||
|
||||
Also pass `--recording-url "https://app.fireflies.ai/view/<id from step 4>"`
|
||||
on every `fill_speakers.py` call in this handoff (both the dry-run and the
|
||||
`--apply` run) — the bare-id form of the URL works without needing to
|
||||
slug-encode the title, and gives the user a direct link to a recording they
|
||||
definitely have full access to (they own the re-uploaded meeting) right
|
||||
next to every label's example lines, for the spot-check
|
||||
`transcript-speaker-fill` asks them to do before confirming. Use the new
|
||||
transcript's id, not the original's — do this *before* step 8's cleanup,
|
||||
while that meeting (and its recording) still exists.
|
||||
|
||||
### 7. Ask where to save the corrected transcript
|
||||
|
||||
Before touching the duplicate meeting, ask the user where the final
|
||||
`--apply`'d output (from step 6) should end up — don't default to leaving
|
||||
it in a scratch/temp location without asking. Common answers: a specific
|
||||
path they name, this project's own `tmp/` (if this skill is being run from
|
||||
inside a Cascade Knowledge Base repo like this one — gitignored, agent-
|
||||
managed), or `raw/inbox/` if they want it ingested into a wiki afterward.
|
||||
Move (don't copy) the file there once they've told you.
|
||||
|
||||
### 8. Delete the duplicate meeting — only after explicit confirmation
|
||||
|
||||
Once the user has confirmed the merged transcript (from step 6) looks right
|
||||
and it's been saved wherever they wanted (step 7), delete the meeting the
|
||||
re-upload created (**not** the original — you don't own that one anyway,
|
||||
and couldn't delete it if you tried):
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fireflies_client.py" delete --id <id from step 4> --yes
|
||||
```
|
||||
|
||||
The script refuses to run without `--yes`. Never pass `--yes` without the
|
||||
user having explicitly confirmed they're ready — this is irreversible and
|
||||
removes a real meeting from their Fireflies account. Show them the printed
|
||||
`{id, title, date, duration}` of what was deleted as final confirmation.
|
||||
|
||||
## Failure modes and what they mean
|
||||
|
||||
- **`fetch` on the original prints the audio_url warning** — see "Before you
|
||||
start" above. This is the expected failure mode when the API key's
|
||||
account isn't Pro+, or when a merely-shared meeting doesn't expose audio
|
||||
via the API. Not a bug to work around silently.
|
||||
- **`upload` mutation succeeds (`success: true`) but `wait` never finds it**
|
||||
— the title match is exact-string, so a `--title` override that doesn't
|
||||
exactly match what you pass to `wait` will never resolve; double check
|
||||
you used the identical string in both commands.
|
||||
- **`wait` times out** — normal for long recordings. Re-run it; don't
|
||||
assume the upload failed.
|
||||
- **Rate limit errors (`too_many_requests`)** — the error message includes
|
||||
`retryAfter`; wait that long before retrying, especially on a Free-tier
|
||||
key (50 requests/day total).
|
||||
- **`delete` fails with `require_elevated_privilege`** — you're trying to
|
||||
delete a transcript you don't own (e.g. you accidentally passed the
|
||||
*original* id instead of the new one from step 4). Only the re-uploaded
|
||||
meeting is yours to delete.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Multiple speakers with a name-recovery gap** (new transcript has more
|
||||
distinct `Speaker N` labels than the original has real names) — this is
|
||||
`transcript-speaker-fill`'s "roster gap" warning, not something this
|
||||
skill's own steps can fix; it means someone's voice wasn't distinctly
|
||||
captured with a real name in the original either.
|
||||
- **Original meeting has `video_url` but not `audio_url`** — `upload` only
|
||||
accepts `--audio-url`; there's no video re-upload path here. If you truly
|
||||
only have video access, extract audio from it yourself first and host
|
||||
that as a public URL, then pass it via `--audio-url`.
|
||||
- **The user wants the corrected transcript ingested into this repo's
|
||||
wiki**, not just saved as a file — step 7 already asks where to save it;
|
||||
if the answer is "ingest it," save it to `raw/inbox/` there, then run the
|
||||
normal "Ingest" workflow from the root `CLAUDE.md` on it after step 8's
|
||||
cleanup (the duplicate meeting is Fireflies-side bookkeeping, unrelated
|
||||
to whether the wiki ingest has happened yet).
|
||||
337
.agents/skills/fireflies-relanguage/scripts/fireflies_client.py
Normal file
337
.agents/skills/fireflies-relanguage/scripts/fireflies_client.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minimal Fireflies.ai GraphQL client for the fireflies-relanguage skill.
|
||||
|
||||
Handles the four network steps of the workflow: pull a transcript you can
|
||||
view (owned or shared) into the Markdown shape transcript-speaker-fill
|
||||
expects, kick off a re-upload of its audio under the correct language,
|
||||
poll until that re-upload shows up as a finished transcript, and delete
|
||||
the resulting duplicate meeting once you're done with it.
|
||||
|
||||
No third-party dependencies - stdlib only (urllib), so this runs with a
|
||||
bare `python3` on any machine that has the transcript-speaker-fill skill
|
||||
installed.
|
||||
|
||||
Requires FIREFLIES_API_KEY in the environment (Settings > API in the
|
||||
Fireflies web app to generate one).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
API_URL = "https://api.fireflies.ai/graphql"
|
||||
GENERIC_RE = re.compile(r'^Speaker\s+\d+$')
|
||||
|
||||
|
||||
def parse_transcript_ref(ref):
|
||||
"""Accept a bare id, or a shared link like
|
||||
https://app.fireflies.ai/view/Some-Title::abcDEF123?channelSource=mine-shared
|
||||
(query string / fragment and the ::title part are both optional) and
|
||||
return just the transcript id."""
|
||||
ref = ref.strip()
|
||||
if '://' in ref:
|
||||
ref = ref.split('?', 1)[0].split('#', 1)[0]
|
||||
ref = ref.rstrip('/').rsplit('/', 1)[-1]
|
||||
if '::' in ref:
|
||||
ref = ref.rsplit('::', 1)[-1]
|
||||
return ref
|
||||
|
||||
|
||||
def gql(api_key, query, variables=None):
|
||||
body = json.dumps({"query": query, "variables": variables or {}}).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
API_URL,
|
||||
data=body,
|
||||
method='POST',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
payload = json.loads(resp.read().decode('utf-8'))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode('utf-8', errors='replace')
|
||||
sys.exit(f"HTTP {e.code} from Fireflies API: {raw}")
|
||||
|
||||
if payload.get('errors'):
|
||||
msgs = []
|
||||
for err in payload['errors']:
|
||||
ext = err.get('extensions') or {}
|
||||
code = ext.get('code', '')
|
||||
msg = err.get('message', '')
|
||||
extra = f" (code={code}" + (f", retryAfter={ext['retryAfter']}" if 'retryAfter' in ext else '') + ')' if code else ''
|
||||
msgs.append(f"{msg}{extra}")
|
||||
sys.exit("Fireflies API returned error(s): " + " | ".join(msgs))
|
||||
return payload.get('data') or {}
|
||||
|
||||
|
||||
def require_api_key():
|
||||
key = os.environ.get('FIREFLIES_API_KEY')
|
||||
if not key:
|
||||
sys.exit(
|
||||
"FIREFLIES_API_KEY is not set. Generate a key in the Fireflies web "
|
||||
"app under Settings > API, then export it, e.g.:\n"
|
||||
" export FIREFLIES_API_KEY=your-key-here"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def format_ts(seconds):
|
||||
seconds = float(seconds)
|
||||
total = int(round(seconds))
|
||||
h, rem = divmod(total, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
TRANSCRIPT_FIELDS = """
|
||||
id
|
||||
title
|
||||
dateString
|
||||
date
|
||||
duration
|
||||
audio_url
|
||||
video_url
|
||||
sentences {
|
||||
speaker_name
|
||||
speaker_id
|
||||
start_time
|
||||
end_time
|
||||
text
|
||||
raw_text
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def cmd_fetch(args):
|
||||
api_key = require_api_key()
|
||||
tid = parse_transcript_ref(args.ref)
|
||||
data = gql(
|
||||
api_key,
|
||||
f"query Transcript($id: String!) {{ transcript(id: $id) {{ {TRANSCRIPT_FIELDS} }} }}",
|
||||
{"id": tid},
|
||||
)
|
||||
t = data.get('transcript')
|
||||
if not t:
|
||||
sys.exit(
|
||||
f"No transcript returned for id '{tid}'. Either the id is wrong, or this "
|
||||
f"API key's account doesn't have access to it (not shared with you, or "
|
||||
f"the workspace/plan doesn't expose it via API)."
|
||||
)
|
||||
|
||||
sentences = t.get('sentences') or []
|
||||
if not sentences:
|
||||
sys.exit(
|
||||
f"Transcript '{t.get('title')}' ({tid}) has no sentences yet - it may "
|
||||
f"still be processing, or your account tier doesn't return transcript "
|
||||
f"content via the API for this meeting."
|
||||
)
|
||||
|
||||
out_path = Path(args.out)
|
||||
lines = []
|
||||
for s in sentences:
|
||||
speaker = s.get('speaker_name') or f"Speaker {s.get('speaker_id', '?')}"
|
||||
text = s.get('text') or s.get('raw_text') or ''
|
||||
ts = format_ts(s.get('start_time', 0))
|
||||
lines.append(f"**{speaker}** *[{ts}]*: {text}")
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding='utf-8')
|
||||
|
||||
meta = {
|
||||
'id': t['id'],
|
||||
'title': t.get('title'),
|
||||
'dateString': t.get('dateString'),
|
||||
'date': t.get('date'),
|
||||
'duration': t.get('duration'),
|
||||
'audio_url': t.get('audio_url'),
|
||||
'video_url': t.get('video_url'),
|
||||
'sentence_count': len(sentences),
|
||||
}
|
||||
meta_path = out_path.with_suffix(out_path.suffix + '.meta.json')
|
||||
meta_path.write_text(json.dumps(meta, indent=2), encoding='utf-8')
|
||||
|
||||
print(f"Wrote {len(lines)} lines to {out_path}")
|
||||
print(f"Wrote metadata to {meta_path}")
|
||||
print(f"Title: {meta['title']}")
|
||||
print(f"audio_url present: {bool(meta['audio_url'])}")
|
||||
print(f"video_url present: {bool(meta['video_url'])}")
|
||||
if not meta['audio_url']:
|
||||
print(
|
||||
"\nWARNING: audio_url is null. This is the expected failure mode when the "
|
||||
"querying account isn't on a Fireflies Pro+ seat, or when audio access "
|
||||
"isn't granted to a merely-shared (non-owned) meeting. Re-uploading this "
|
||||
"meeting's audio via the API is NOT possible until this is resolved - "
|
||||
"either use an API key belonging to a Pro+ seat, ask the meeting owner to "
|
||||
"reprocess it directly, or manually download the audio via the Fireflies "
|
||||
"web UI (if the share settings allow it) and host it at a public HTTPS URL "
|
||||
"yourself before using the `upload` command's --audio-url override.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def cmd_upload(args):
|
||||
api_key = require_api_key()
|
||||
meta = json.loads(Path(args.meta).read_text(encoding='utf-8'))
|
||||
|
||||
audio_url = args.audio_url or meta.get('audio_url')
|
||||
if not audio_url:
|
||||
sys.exit(
|
||||
"No audio_url available (neither in the meta file nor via --audio-url). "
|
||||
"See the WARNING printed by the `fetch` command for why, and how to work "
|
||||
"around it."
|
||||
)
|
||||
|
||||
original_id = meta['id']
|
||||
title = args.title or f"{meta.get('title', 'Meeting')} [relang-{original_id}]"
|
||||
|
||||
variables = {
|
||||
"input": {
|
||||
"url": audio_url,
|
||||
"title": title,
|
||||
"custom_language": args.language,
|
||||
"client_reference_id": original_id,
|
||||
}
|
||||
}
|
||||
if args.bypass_size_check:
|
||||
variables["input"]["bypass_size_check"] = True
|
||||
|
||||
data = gql(
|
||||
api_key,
|
||||
"""
|
||||
mutation UploadAudio($input: AudioUploadInput!) {
|
||||
uploadAudio(input: $input) {
|
||||
success
|
||||
title
|
||||
message
|
||||
}
|
||||
}
|
||||
""",
|
||||
variables,
|
||||
)
|
||||
result = data.get('uploadAudio') or {}
|
||||
echoed_title = result.get('title') or title
|
||||
print(json.dumps({
|
||||
"submitted_title": title,
|
||||
"language": args.language,
|
||||
"source_transcript_id": original_id,
|
||||
"api_response": result,
|
||||
}, indent=2))
|
||||
if not result.get('success'):
|
||||
sys.exit("uploadAudio did not report success - check the message above.")
|
||||
if echoed_title != title:
|
||||
print(
|
||||
f"\nNOTE: Fireflies echoed back a different title than submitted "
|
||||
f"(likely stripped/altered some character) - use the ECHOED one below "
|
||||
f"for `wait`, not what you submitted."
|
||||
)
|
||||
print(
|
||||
f"\nQueued. Use the `wait` command with --title '{echoed_title}' to find the "
|
||||
f"new transcript once processing finishes (this can take several minutes for "
|
||||
f"a long recording)."
|
||||
)
|
||||
|
||||
|
||||
def cmd_wait(args):
|
||||
api_key = require_api_key()
|
||||
deadline = time.time() + args.max_wait
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
data = gql(
|
||||
api_key,
|
||||
"""
|
||||
query Transcripts($limit: Int) {
|
||||
transcripts(mine: true, limit: $limit) {
|
||||
id
|
||||
title
|
||||
dateString
|
||||
sentences { speaker_name }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"limit": args.list_limit},
|
||||
)
|
||||
candidates = [t for t in (data.get('transcripts') or []) if t.get('title') == args.title]
|
||||
ready = [t for t in candidates if t.get('sentences')]
|
||||
if ready:
|
||||
t = ready[0]
|
||||
print(json.dumps({"id": t['id'], "title": t['title'], "dateString": t.get('dateString'), "ready": True}, indent=2))
|
||||
return
|
||||
if candidates:
|
||||
print(f"[attempt {attempt}] Found the meeting but it's still processing (no sentences yet)...", file=sys.stderr)
|
||||
else:
|
||||
print(f"[attempt {attempt}] Not found yet...", file=sys.stderr)
|
||||
if time.time() >= deadline:
|
||||
sys.exit(
|
||||
f"Gave up after {args.max_wait}s without finding a ready transcript "
|
||||
f"titled '{args.title}'. Long recordings can take longer than that to "
|
||||
f"process - re-run `wait` again with a fresh --max-wait, or check the "
|
||||
f"Fireflies web UI directly for a meeting with that title."
|
||||
)
|
||||
time.sleep(args.interval)
|
||||
|
||||
|
||||
def cmd_delete(args):
|
||||
if not args.yes:
|
||||
sys.exit("Refusing to delete without --yes (this is irreversible).")
|
||||
api_key = require_api_key()
|
||||
data = gql(
|
||||
api_key,
|
||||
"""
|
||||
mutation DeleteTranscript($id: String!) {
|
||||
deleteTranscript(id: $id) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"id": args.id},
|
||||
)
|
||||
print(json.dumps(data.get('deleteTranscript') or {}, indent=2))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest='command', required=True)
|
||||
|
||||
p = sub.add_parser('fetch', help="Fetch a transcript (owned or shared) into transcript-speaker-fill-compatible Markdown + a metadata sidecar.")
|
||||
p.add_argument('--ref', required=True, help='Transcript id, or a Fireflies share URL/link')
|
||||
p.add_argument('--out', required=True, help='Output .md path')
|
||||
p.set_defaults(func=cmd_fetch)
|
||||
|
||||
p = sub.add_parser('upload', help="Re-upload a transcript's audio under a different language via uploadAudio.")
|
||||
p.add_argument('--meta', required=True, help="Path to the .meta.json produced by `fetch` for the ORIGINAL (broken-language) transcript")
|
||||
p.add_argument('--language', required=True, help='Target language code, e.g. "en", "pl", "es"')
|
||||
p.add_argument('--title', help='Override the title used for the new upload (default: original title + a [relang-<id>] tag)')
|
||||
p.add_argument('--audio-url', help='Override the audio URL instead of using the one from --meta (e.g. a self-hosted fallback URL)')
|
||||
p.add_argument('--bypass-size-check', action='store_true')
|
||||
p.set_defaults(func=cmd_upload)
|
||||
|
||||
p = sub.add_parser('wait', help='Poll until the re-uploaded transcript shows up as fully processed.')
|
||||
p.add_argument('--title', required=True, help='Exact title echoed back by `upload` (not necessarily what you submitted - Fireflies can alter it, e.g. stripping colons)')
|
||||
p.add_argument('--interval', type=int, default=30, help='Seconds between polls (default 30)')
|
||||
p.add_argument('--max-wait', type=int, default=900, help='Give up after this many seconds (default 900 = 15 min)')
|
||||
p.add_argument('--list-limit', type=int, default=20, help="How many of your most recent transcripts to scan each poll for an exact title match (default 20; server 'keyword' search was found unreliable against bracket-tagged titles, so this lists recent transcripts client-side instead of filtering server-side)")
|
||||
p.set_defaults(func=cmd_wait)
|
||||
|
||||
p = sub.add_parser('delete', help='Delete a transcript by id (irreversible).')
|
||||
p.add_argument('--id', required=True)
|
||||
p.add_argument('--yes', action='store_true', help='Required confirmation flag')
|
||||
p.set_defaults(func=cmd_delete)
|
||||
|
||||
args = ap.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
13
.agents/skills/invoice-checker/.env.example
Normal file
13
.agents/skills/invoice-checker/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Invoice Checker — credentials template
|
||||
# Copy this file to .env (in the project root) and fill in the values.
|
||||
# .env is gitignored and never committed.
|
||||
|
||||
# Your company NIP — 10 digits, no dashes or spaces
|
||||
KSEF_NIP=1111111111
|
||||
|
||||
# KSeF authorization token — generate in the KSeF portal under Zarządzanie tokenami
|
||||
# Grant it at minimum: "Odczyt faktur" (invoice read) permissions.
|
||||
KSEF_TOKEN=20260601-EC-25684C5000-A33DFDC343-5F|nip-1111111111|4b6eaeb9027d45428fe714ff2a32a8f75bae7b2b83c14fb1bd64a60543767df5
|
||||
|
||||
# Environment: "test" (sandbox) or "prod" (production)
|
||||
KSEF_ENV=test
|
||||
1
.agents/skills/invoice-checker/.gitignore
vendored
Normal file
1
.agents/skills/invoice-checker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
116
.agents/skills/invoice-checker/SKILL.md
Normal file
116
.agents/skills/invoice-checker/SKILL.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
---
|
||||
name: invoice-checker
|
||||
description: >
|
||||
Pull and check invoices from KSeF (Krajowy System e-Faktur) for a selected period.
|
||||
For every new invoice checks whether the contractor is in the contractors file and
|
||||
whether the contractor's bank account is on the MF white list (Biała lista podatników VAT).
|
||||
TRIGGER this skill automatically whenever the user mentions pulling, checking, reviewing,
|
||||
or auditing invoices from KSeF — especially when they mention a period like last week,
|
||||
last month, or a date range. Trigger phrases include: "check invoices", "pull invoices",
|
||||
"invoice audit", "KSeF invoices", "check KSeF", "white list check", "biała lista".
|
||||
---
|
||||
|
||||
# Invoice Checker
|
||||
|
||||
Use this skill when someone needs to download purchase invoices from KSeF for a given period, verify contractors, and check bank accounts against the MF white list.
|
||||
|
||||
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This skill reads credentials from the `.env` file in the **project root** (the working directory where Claude Code is open). The same file is shared with the timesheet-checker skill — just add the KSEF variables to it.
|
||||
|
||||
Add these lines to your `.env`:
|
||||
|
||||
```env
|
||||
# KSeF — Invoice Checker
|
||||
KSEF_NIP= # 10-digit company NIP, no dashes
|
||||
KSEF_TOKEN= # Authorization token from KSeF portal → Zarządzanie tokenami
|
||||
KSEF_ENV=prod # "test" for sandbox, "prod" for production
|
||||
```
|
||||
|
||||
The variable reference:
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `KSEF_NIP` | Company NIP (10 digits, no dashes) | Your company's tax ID |
|
||||
| `KSEF_TOKEN` | KSeF authorization token | KSeF portal → Zarządzanie tokenami |
|
||||
| `KSEF_ENV` | `test` or `prod` | Use `test` for the sandbox, `prod` for production |
|
||||
|
||||
The script also requires the `cryptography` Python package:
|
||||
|
||||
```bash
|
||||
pip3 install cryptography
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./invoices-output/` in the current working directory. Create it if it does not exist.
|
||||
|
||||
## Load First
|
||||
|
||||
Read these reference files:
|
||||
|
||||
1. The contractors config — check `./contractors.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/contractors.md`. Tell the user which one is being used.
|
||||
2. `{SKILL_DIR}/references/output-format.md` — the structure of `invoices_data.json` and the final report.
|
||||
|
||||
## Extracting the period
|
||||
|
||||
The user may express the period in various ways. Convert to `YYYY-MM-DD:YYYY-MM-DD` before running:
|
||||
|
||||
| User says | Start | End |
|
||||
|---|---|---|
|
||||
| "last week" | Last Monday | Last Friday |
|
||||
| "last month" | 1st of previous month | Last day of previous month |
|
||||
| "this month" | 1st of current month | Yesterday |
|
||||
| "this year" | 1 January of current year | Yesterday |
|
||||
| "June", "June 2026" | 2026-06-01 | 2026-06-30 |
|
||||
| Explicit dates | As given | As given |
|
||||
|
||||
If the user has not specified a period, ask: "Który okres sprawdzić? (np. zeszły tydzień, zeszły miesiąc, zakres dat)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 — Fetch and check.** Run this command:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/preprocess.py \
|
||||
--period {YYYY-MM-DD:YYYY-MM-DD} \
|
||||
--contractors {contractors_path} \
|
||||
--output-dir ./invoices-output
|
||||
```
|
||||
|
||||
This authenticates with KSeF, downloads all purchase invoices for the period, compares against `invoices_cache.json` to identify new invoices, checks each contractor against `contractors.md`, checks bank accounts against the MF white list, and writes `invoices_data.json` to `./invoices-output/`.
|
||||
|
||||
**Step 2 — Interpret.** Read `./invoices-output/invoices_data.json`. Note:
|
||||
- **New vs known invoices** — only new ones (not in cache) are actively checked.
|
||||
- **Unknown contractors** — contractors not in `contractors.md`. Show these to the user and ask if they should be added.
|
||||
- **White list failures** — bank accounts not found on the white list. These are high priority — flag them prominently.
|
||||
- **White list errors** — API call failed (network issue, etc.). Note that these could not be verified.
|
||||
|
||||
**Step 3 — Generate report.**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/postprocess.py \
|
||||
--data ./invoices-output/invoices_data.json \
|
||||
--output-dir ./invoices-output
|
||||
```
|
||||
|
||||
This writes `invoices_report.md`.
|
||||
|
||||
**Step 4 — Present results.** Show the user:
|
||||
- Summary block: total invoices, new invoices, contractor status, white list status.
|
||||
- Table of new invoices with contractor and white list status.
|
||||
- Any white list failures or unknown contractors — highlighted prominently.
|
||||
- "Full report saved to `./invoices-output/invoices_report.md`."
|
||||
|
||||
**Step 5 — Handle unknown contractors.** If the user confirms any unknown contractors should be added, add them to `contractors.md`. Use the NIP and name from the invoice; leave `bank_accounts` blank if they want to fill it in later, or populate it from the invoice bank account.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never fabricate invoice data. All data comes from KSeF.
|
||||
- White list checks are always performed against the invoice issue date (not today).
|
||||
- If `KSEF_NIP` or `KSEF_TOKEN` are not set, stop and ask the user for them.
|
||||
- If the `cryptography` package is missing, tell the user to run `pip3 install cryptography` and stop.
|
||||
- If any script step fails, show the error and stop. Do not proceed with partial output.
|
||||
- Always terminate the KSeF session even if an error occurs (the script handles this internally).
|
||||
15
.agents/skills/invoice-checker/references/contractors.md
Normal file
15
.agents/skills/invoice-checker/references/contractors.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Contractors
|
||||
|
||||
Add a row for each contractor. Use their NIP as the primary identifier — the script matches invoices by NIP.
|
||||
|
||||
`bank_accounts` is a semicolon-separated list of IBAN numbers (with or without the PL prefix, spaces ignored).
|
||||
Leave it blank if you do not want to maintain expected accounts — the white list check will still run against the account from the invoice.
|
||||
|
||||
`max_invoice_net_value` — maximum net total (PLN) allowed for a single invoice. Leave blank for no limit.
|
||||
`max_position_net_rate` — maximum unit price (net) allowed on any single line item. Leave blank for no limit.
|
||||
`max_total_quantity` — maximum sum of quantities across all line items on a single invoice. Leave blank for no limit.
|
||||
`email` — contact email address for the contractor (used for notifications).
|
||||
`check_with_jira` — set to `yes` (or `true` / `1`) to indicate invoices from this contractor should be cross-checked with Jira.
|
||||
|
||||
| name | nip | bank_accounts | max_invoice_net_value | max_position_net_rate | max_total_quantity | email | check_with_jira | notes |
|
||||
| -----| ---| --------------| ----------------------| ----------------------| -------------------| ------| ----------------| ------|
|
||||
70
.agents/skills/invoice-checker/references/output-format.md
Normal file
70
.agents/skills/invoice-checker/references/output-format.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Output Format
|
||||
|
||||
## invoices_data.json (written by preprocess.py)
|
||||
|
||||
```json
|
||||
{
|
||||
"period": {
|
||||
"start": "2026-06-01",
|
||||
"end": "2026-06-30"
|
||||
},
|
||||
"summary": {
|
||||
"total_invoices": 12,
|
||||
"new_invoices": 4,
|
||||
"known_contractors": 3,
|
||||
"unknown_contractors": 1,
|
||||
"whitelist_ok": 3,
|
||||
"whitelist_failed": 1,
|
||||
"whitelist_error": 0,
|
||||
"account_mismatch": 0
|
||||
},
|
||||
"invoices": [
|
||||
{
|
||||
"ksef_reference": "1234567890-20260601-ABC123",
|
||||
"invoice_number": "FV/123/06/2026",
|
||||
"issue_date": "2026-06-01",
|
||||
"seller_nip": "1234567890",
|
||||
"seller_name": "Firma ABC Sp. z o.o.",
|
||||
"gross_amount": 1230.00,
|
||||
"currency": "PLN",
|
||||
"bank_account": "PL61109010140000071219812874",
|
||||
"is_new": true,
|
||||
"contractor_known": true,
|
||||
"account_in_contractors": true,
|
||||
"whitelist_status": "ok",
|
||||
"whitelist_account_assigned": true,
|
||||
"whitelist_error": null
|
||||
}
|
||||
],
|
||||
"known_invoices_count": 8
|
||||
}
|
||||
```
|
||||
|
||||
### Field notes
|
||||
|
||||
- `is_new` — true if the KSeF reference was not in `invoices_cache.json` before this run. The cache is updated at the end of a successful run.
|
||||
- `contractor_known` — true if the seller NIP is in `contractors.md`.
|
||||
- `account_in_contractors` — true if the invoice bank account matches one of the accounts listed in `contractors.md` for this NIP. `null` if the contractor is unknown or has no accounts configured.
|
||||
- `whitelist_status` — `"ok"` (account found on white list), `"failed"` (account NOT on white list), `"no_account"` (invoice has no bank account), `"error"` (API call failed).
|
||||
- `whitelist_account_assigned` — the raw `accountAssigned` value from the MF API (`true`/`false`). `null` if not checked.
|
||||
- `whitelist_error` — error message string if the API call failed, otherwise `null`.
|
||||
|
||||
## invoices_report.md (written by postprocess.py)
|
||||
|
||||
Manager-readable markdown report containing:
|
||||
- Period and run timestamp
|
||||
- Summary block
|
||||
- Table of new invoices with contractor and white list status
|
||||
- Section highlighting white list failures (if any)
|
||||
- Section listing unknown contractors (if any)
|
||||
|
||||
### Status icons used in the report
|
||||
|
||||
| Icon | Meaning |
|
||||
|---|---|
|
||||
| ✓ | White list OK |
|
||||
| ✗ | White list FAILED — do not pay until resolved |
|
||||
| ? | Could not verify (API error) |
|
||||
| — | No bank account on invoice |
|
||||
| ★ | Known contractor |
|
||||
| ✦ | Unknown contractor |
|
||||
421
.agents/skills/invoice-checker/scripts/postprocess.py
Normal file
421
.agents/skills/invoice-checker/scripts/postprocess.py
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Format invoice check data into a markdown report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fmt_amount(amount: Any, currency: str = "PLN") -> str:
|
||||
if amount is None:
|
||||
return "—"
|
||||
try:
|
||||
return f"{float(amount):,.2f} {currency}"
|
||||
except (TypeError, ValueError):
|
||||
return str(amount)
|
||||
|
||||
|
||||
def contractor_badge(inv: dict) -> str:
|
||||
if inv.get("contractor_known"):
|
||||
mismatch = inv.get("account_in_contractors") is False
|
||||
return "★" + (" ⚠acc" if mismatch else "")
|
||||
return "✦ NEW"
|
||||
|
||||
|
||||
def whitelist_badge(inv: dict) -> str:
|
||||
status = inv.get("whitelist_status", "")
|
||||
if status == "ok":
|
||||
return "✓"
|
||||
if status == "failed":
|
||||
return "✗ FAILED"
|
||||
if status == "no_account":
|
||||
return "—"
|
||||
if status == "error":
|
||||
return "? err"
|
||||
if status == "no_nip":
|
||||
return "? no NIP"
|
||||
return status
|
||||
|
||||
|
||||
def rules_badge(inv: dict) -> str:
|
||||
violations = inv.get("rule_violations") or []
|
||||
return f"⚠ {len(violations)}" if violations else "✓"
|
||||
|
||||
|
||||
def duplicate_badge(inv: dict) -> str:
|
||||
if inv.get("potential_duplicate"):
|
||||
return "⚠ DUPE?"
|
||||
if inv.get("second_in_month"):
|
||||
return "2nd"
|
||||
return ""
|
||||
|
||||
|
||||
def jira_badge(inv: dict) -> str:
|
||||
if not inv.get("jira_checked"):
|
||||
return ""
|
||||
if inv.get("jira_compliant"):
|
||||
return "✓"
|
||||
return "✗"
|
||||
|
||||
|
||||
def _invoice_table_row(inv: dict) -> str:
|
||||
return (
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('issue_date') or '—'} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} "
|
||||
f"| {contractor_badge(inv)} "
|
||||
f"| {whitelist_badge(inv)} "
|
||||
f"| {rules_badge(inv)} "
|
||||
f"| {duplicate_badge(inv)} "
|
||||
f"| {jira_badge(inv)} |"
|
||||
)
|
||||
|
||||
|
||||
def _invoice_table_header() -> list[str]:
|
||||
return [
|
||||
"| Invoice | Date | Seller | NIP | Amount | Contractor | White list | Rules | Dup. | Jira |",
|
||||
"| ---| ---| ---| ---| ---:| ---| ---| ---| ---| ---|",
|
||||
]
|
||||
|
||||
|
||||
def _legend() -> list[str]:
|
||||
return [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"**Legend:** ★ = known contractor · ✦ NEW = unknown contractor · "
|
||||
"✓ = white list OK · ✗ FAILED = not on white list · "
|
||||
"— = no bank account · ? = could not verify · ⚠acc = account not in contractors.md · "
|
||||
"⚠ N = N rule violation(s) · ✓ = rules OK · "
|
||||
"2nd = 2nd invoice from seller this month · ⚠ DUPE? = same amount as earlier invoice · "
|
||||
"✓/✗ Jira = Jira compliance check result",
|
||||
"",
|
||||
"_White list source: [wl-api.mf.gov.pl](https://wl-api.mf.gov.pl)_",
|
||||
"_Invoice source: KSeF (Ministerstwo Finansów)_",
|
||||
]
|
||||
|
||||
|
||||
def build_report(data: dict[str, Any]) -> str:
|
||||
"""Build the per-run report covering only new invoices."""
|
||||
period = data["period"]
|
||||
summary = data["summary"]
|
||||
all_invoices = data.get("invoices", [])
|
||||
new_invoices = [i for i in all_invoices if i.get("is_new")]
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
env = data.get("ksef_env", "prod")
|
||||
|
||||
lines = [
|
||||
"# Invoice Check Report",
|
||||
"",
|
||||
f"Generated: {generated_at}",
|
||||
f"Period: {period['start']} to {period['end']}",
|
||||
f"KSeF environment: {env}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total invoices in period: **{summary['total_invoices']}**",
|
||||
f"- New (checked this run): **{summary['new_invoices']}**",
|
||||
f"- Known contractors: {summary['known_contractors']}",
|
||||
f"- Unknown contractors: **{summary['unknown_contractors']}**",
|
||||
f"- White list OK: {summary['whitelist_ok']}",
|
||||
f"- White list FAILED: **{summary['whitelist_failed']}**",
|
||||
f"- White list errors: {summary['whitelist_error']}",
|
||||
f"- Account mismatch (not in contractors.md): {summary['account_mismatch']}",
|
||||
f"- Rule violations (limit exceeded): **{summary.get('rule_violations', 0)}**",
|
||||
f"- 2nd invoice from same seller this month: **{summary.get('second_invoices', 0)}**",
|
||||
f"- Potential duplicates (same seller, same month, same amount): **{summary.get('potential_duplicates', 0)}**",
|
||||
f"- Jira non-compliant: **{summary.get('jira_non_compliant', 0)}**",
|
||||
]
|
||||
|
||||
# Jira non-compliance — prominent
|
||||
jira_bad = [i for i in new_invoices if i.get("jira_checked") and not i.get("jira_compliant")]
|
||||
if jira_bad:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ Jira Non-Compliant Invoices",
|
||||
"",
|
||||
"> Quantities on these invoices do not match hours logged in Jira/Tempo.",
|
||||
"",
|
||||
]
|
||||
for inv in jira_bad:
|
||||
lines += [
|
||||
f"**{inv.get('invoice_number') or inv.get('ksef_reference', '—')}** "
|
||||
f"— {inv.get('seller_name') or '—'} ({inv.get('issue_date') or '—'})",
|
||||
"",
|
||||
]
|
||||
violations = inv.get("jira_violations") or []
|
||||
no_proj = inv.get("jira_no_project_positions") or []
|
||||
if violations:
|
||||
lines += [
|
||||
"| Project | Invoice qty | Jira hours | Month |",
|
||||
"| ---| ---:| ---:| ---|",
|
||||
]
|
||||
for v in violations:
|
||||
lines.append(
|
||||
f"| {v['project']} | {v['invoice_qty']} | {v['jira_hours']} | {v['month']} |"
|
||||
)
|
||||
lines.append("")
|
||||
if no_proj:
|
||||
lines.append(
|
||||
"Positions without a Jira project code: "
|
||||
+ ", ".join(
|
||||
f"#{p['line_number']} ({p['description'] or 'no description'})"
|
||||
for p in no_proj
|
||||
)
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Potential duplicates — prominent
|
||||
dupes = [i for i in new_invoices if i.get("potential_duplicate")]
|
||||
if dupes:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Potential Duplicates",
|
||||
"",
|
||||
"> These invoices have the same seller and gross amount as another invoice in the same month.",
|
||||
"> Verify they are not duplicate payments before approving.",
|
||||
"",
|
||||
"| Invoice | Date | Seller | NIP | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in dupes:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('issue_date') or '—'} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# Rule violations — prominent
|
||||
rule_violated = [i for i in new_invoices if i.get("rule_violations")]
|
||||
if rule_violated:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Rule Violations",
|
||||
"",
|
||||
"> These invoices exceed limits configured in `contractors.md`.",
|
||||
"",
|
||||
"| Invoice | Seller | Rule | Limit | Actual | Position |",
|
||||
"| ---| ---| ---| ---:| ---:| ---|",
|
||||
]
|
||||
for inv in rule_violated:
|
||||
for v in inv.get("rule_violations", []):
|
||||
rule_label = {
|
||||
"max_invoice_net_value": "Max invoice net",
|
||||
"max_position_net_rate": "Max position rate",
|
||||
"max_total_quantity": "Max total qty",
|
||||
}.get(v["rule"], v["rule"])
|
||||
pos_info = f"#{v['position']} {v.get('description', '')}" if v.get("position") else "—"
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {rule_label} "
|
||||
f"| {v['limit']:,.2f} "
|
||||
f"| {v['actual']:,.2f} "
|
||||
f"| {pos_info} |"
|
||||
)
|
||||
|
||||
# White list failures — prominent
|
||||
failed = [i for i in new_invoices if i.get("whitelist_status") == "failed"]
|
||||
if failed:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ White List Failures — DO NOT PAY",
|
||||
"",
|
||||
"> These bank accounts are **not registered** on the MF white list for the given NIP.",
|
||||
"> Verify manually before making any payment.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Bank account | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in failed:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# New invoices table
|
||||
if new_invoices:
|
||||
lines += ["", "## New Invoices", ""] + _invoice_table_header()
|
||||
for inv in new_invoices:
|
||||
lines.append(_invoice_table_row(inv))
|
||||
else:
|
||||
lines += ["", "## New Invoices", "", "_No new invoices found for this period._"]
|
||||
|
||||
# Unknown contractors
|
||||
unknown = [i for i in new_invoices if not i.get("contractor_known")]
|
||||
if unknown:
|
||||
lines += [
|
||||
"",
|
||||
"## ✦ Unknown Contractors",
|
||||
"",
|
||||
"These sellers are not in `contractors.md`. Consider adding them.",
|
||||
"",
|
||||
"| Seller name | NIP | Bank account |",
|
||||
"| ---| ---| ---|",
|
||||
]
|
||||
seen_nips: set[str] = set()
|
||||
for inv in unknown:
|
||||
nip = inv.get("seller_nip") or "—"
|
||||
if nip in seen_nips:
|
||||
continue
|
||||
seen_nips.add(nip)
|
||||
lines.append(
|
||||
f"| {inv.get('seller_name') or '—'} | {nip} | {inv.get('bank_account') or '—'} |"
|
||||
)
|
||||
|
||||
# Account mismatches
|
||||
mismatches = [i for i in new_invoices if i.get("account_in_contractors") is False]
|
||||
if mismatches:
|
||||
lines += [
|
||||
"",
|
||||
"## ⚠ Account Mismatches",
|
||||
"",
|
||||
"These invoices carry a bank account **not listed** in `contractors.md` for the seller.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Invoice account |",
|
||||
"| ---| ---| ---| ---|",
|
||||
]
|
||||
for inv in mismatches:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} |"
|
||||
)
|
||||
|
||||
# White list errors
|
||||
errors = [i for i in new_invoices if i.get("whitelist_status") == "error"]
|
||||
if errors:
|
||||
lines += [
|
||||
"",
|
||||
"## ? White List Check Errors",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Error |",
|
||||
"| ---| ---| ---| ---|",
|
||||
]
|
||||
for inv in errors:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('whitelist_error') or '—'} |"
|
||||
)
|
||||
|
||||
lines += _legend()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def build_all_report(all_data: dict[str, Any]) -> str:
|
||||
"""Build the cumulative report covering all invoices ever processed."""
|
||||
updated_at = all_data.get("updated_at", "")[:19].replace("T", " ") + " UTC"
|
||||
invoices_map: dict[str, dict] = all_data.get("invoices", {})
|
||||
|
||||
# Sort by issue_date descending, then by ksef_reference for stability
|
||||
invoices = sorted(
|
||||
invoices_map.values(),
|
||||
key=lambda i: (i.get("issue_date") or "0000-00-00", i.get("ksef_reference", "")),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total = len(invoices)
|
||||
wl_ok = sum(1 for i in invoices if i.get("whitelist_status") == "ok")
|
||||
wl_failed = sum(1 for i in invoices if i.get("whitelist_status") == "failed")
|
||||
unknown_count = sum(1 for i in invoices if not i.get("contractor_known"))
|
||||
gross_total = sum(float(i["gross_amount"]) for i in invoices if i.get("gross_amount") is not None)
|
||||
|
||||
lines = [
|
||||
"# All Invoices",
|
||||
"",
|
||||
f"Last updated: {updated_at}",
|
||||
f"Total: **{total}** invoice(s)",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Total invoices: **{total}**",
|
||||
f"- Total gross value: **{gross_total:,.2f} PLN**",
|
||||
f"- White list OK: {wl_ok}",
|
||||
f"- White list FAILED: **{wl_failed}**",
|
||||
f"- Unknown contractors: **{unknown_count}**",
|
||||
]
|
||||
|
||||
# White list failures — prominent
|
||||
failed = [i for i in invoices if i.get("whitelist_status") == "failed"]
|
||||
if failed:
|
||||
lines += [
|
||||
"",
|
||||
"## ✗ White List Failures — DO NOT PAY",
|
||||
"",
|
||||
"> These bank accounts are **not registered** on the MF white list.",
|
||||
"",
|
||||
"| Invoice | Seller | NIP | Bank account | Amount |",
|
||||
"| ---| ---| ---| ---| ---:|",
|
||||
]
|
||||
for inv in failed:
|
||||
lines.append(
|
||||
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
||||
f"| {inv.get('seller_name') or '—'} "
|
||||
f"| {inv.get('seller_nip') or '—'} "
|
||||
f"| {inv.get('bank_account') or '—'} "
|
||||
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
||||
)
|
||||
|
||||
# Full invoices table
|
||||
lines += ["", "## All Invoices (newest first)", ""] + _invoice_table_header()
|
||||
for inv in invoices:
|
||||
lines.append(_invoice_table_row(inv))
|
||||
|
||||
lines += _legend()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
data_path = Path(args.data)
|
||||
if not data_path.exists():
|
||||
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Per-run report (new invoices only)
|
||||
report_path = output_dir / "invoices_report.md"
|
||||
report_path.write_text(build_report(data), encoding="utf-8")
|
||||
print(f"Wrote {report_path}")
|
||||
|
||||
# Cumulative report (all invoices ever processed)
|
||||
all_data_path = output_dir / "invoices_all.json"
|
||||
if all_data_path.exists():
|
||||
all_data = json.loads(all_data_path.read_text(encoding="utf-8"))
|
||||
all_report_path = output_dir / "invoices_all_report.md"
|
||||
all_report_path.write_text(build_all_report(all_data), encoding="utf-8")
|
||||
print(f"Wrote {all_report_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Format invoice check data into markdown reports."
|
||||
)
|
||||
parser.add_argument("--data", required=True,
|
||||
help="Path to invoices_data.json produced by preprocess.py.")
|
||||
parser.add_argument("--output-dir", required=True,
|
||||
help="Directory for invoices_report.md and invoices_all_report.md.")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
1243
.agents/skills/invoice-checker/scripts/preprocess.py
Normal file
1243
.agents/skills/invoice-checker/scripts/preprocess.py
Normal file
File diff suppressed because it is too large
Load diff
84
.agents/skills/invoice-prep/SKILL.md
Normal file
84
.agents/skills/invoice-prep/SKILL.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
---
|
||||
name: invoice-prep
|
||||
description: >
|
||||
Show what to put on an invoice for a selected month by checking the user's
|
||||
own Tempo timesheets. Lists every Jira project code and the total hours logged
|
||||
against it. Warns if there are missing hours or days with no logged time.
|
||||
TRIGGER when user asks what to put on an invoice, what to invoice, invoice
|
||||
preparation, timesheet summary for invoicing, or similar phrases.
|
||||
Trigger phrases: "what should I put on an invoice", "prepare my invoice",
|
||||
"invoice for [month]", "what to invoice for", "check my TS for invoice".
|
||||
---
|
||||
|
||||
# Invoice Preparation
|
||||
|
||||
Use this skill when someone wants to know what project codes and hours to put on their invoice for a given month. It reads their own Tempo worklogs, groups hours by Jira project, and warns about any missing time.
|
||||
|
||||
**In Claude Code, you run the pipeline automatically.** The user provides a month — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Uses the same credentials as the timesheet-checker — no extra setup needed:
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `TEMPO_API_TOKEN` | Fetch worklogs from Tempo |
|
||||
| `JIRA_BASE_URL` | Resolve the user's account ID |
|
||||
| `JIRA_EMAIL` | Identifies whose timesheets to read |
|
||||
| `JIRA_API_TOKEN` | Authenticate with Jira |
|
||||
|
||||
All four must be present in the `.env` file in the project root.
|
||||
|
||||
## Extracting the month
|
||||
|
||||
Convert natural language to `YYYY-MM` before running:
|
||||
|
||||
| User says | CLI value |
|
||||
|---|---|
|
||||
| "last month" | Previous calendar month |
|
||||
| "this month" | Current calendar month |
|
||||
| "June", "June 2026" | `2026-06` |
|
||||
| "May" | `2026-05` (current year) |
|
||||
|
||||
If no month is specified, ask: "Which month should I check? (e.g. last month, June 2026)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 — Run the script:**
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/run.py \
|
||||
--month {YYYY-MM} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This resolves the user's Jira account, fetches their Tempo worklogs for the full calendar month, groups hours by Jira project code, checks completeness against the Polish working calendar, and writes `invoice_prep_{YYYY-MM}.json` to `./timesheet-output/`.
|
||||
|
||||
**Step 2 — Present results.** Read the output JSON and show the user:
|
||||
|
||||
1. A table of project codes and hours — formatted ready to copy onto an invoice.
|
||||
Include **every project in the `projects` array**. Do NOT label any project as "non-billable" or omit it from the table — the script already excludes ignored issues; everything remaining is billable:
|
||||
|
||||
```
|
||||
| Project | Hours |
|
||||
| IAA | 45.5 |
|
||||
| PROJ | 22.0 |
|
||||
```
|
||||
|
||||
Below the table, note ignored issues and their hours as a parenthetical: `(INTERNAL-1: 40h ignored per config.)` — do not mix them into the billable table.
|
||||
|
||||
2. The **total billable hours** from `total_billable_hours` (not `total_logged_hours`).
|
||||
|
||||
3. If `missing_hours > 0`: a clear warning showing:
|
||||
- How many hours are missing vs the expected total for the month
|
||||
- Which specific dates have no logged time (up to 10; if more, show count)
|
||||
- Reminder to complete the timesheet before issuing the invoice
|
||||
|
||||
4. If `total_logged_hours == 0`: warn that no hours were found at all for the month.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never fabricate or estimate hours. All data comes from Tempo.
|
||||
- If `JIRA_EMAIL` is not set, stop and tell the user.
|
||||
- If any script step fails, show the error and stop.
|
||||
- Always remind the user that incomplete timesheets will cause Jira compliance failures when the invoice is checked.
|
||||
353
.agents/skills/invoice-prep/scripts/run.py
Normal file
353
.agents/skills/invoice-prep/scripts/run.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate invoice line items from the user's own Tempo worklogs for a given month.
|
||||
|
||||
Groups hours by Jira project code and reports missing time vs the Polish
|
||||
working calendar.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import calendar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Period helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_month(month_str: str) -> tuple[date, date]:
|
||||
"""Parse 'YYYY-MM' into (first_day, last_day) of that month."""
|
||||
s = month_str.strip()
|
||||
if not re.fullmatch(r"\d{4}-\d{2}", s):
|
||||
raise ValueError(f"Invalid month '{month_str}'. Use YYYY-MM.")
|
||||
year, month = int(s[:4]), int(s[5:7])
|
||||
first = date(year, month, 1)
|
||||
last = date(year, month, calendar.monthrange(year, month)[1])
|
||||
return first, last
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_public_holidays(country: str, year: int) -> set[date]:
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "invoice-prep/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return {date.fromisoformat(item["date"]) for item in json.loads(resp.read())}
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not fetch holidays for {country} {year}: {exc}", file=sys.stderr)
|
||||
return set()
|
||||
|
||||
|
||||
def working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
result, cur = [], start
|
||||
while cur <= end:
|
||||
if cur.weekday() < 5 and cur not in holidays:
|
||||
result.append(cur)
|
||||
cur += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Jira account lookup ───────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_id(email: str, jira_base_url: str,
|
||||
jira_email: str, jira_token: str) -> str:
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=5"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
users = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print("Error: Jira API returned 401. Check JIRA_EMAIL and JIRA_API_TOKEN.",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Jira API {exc.code}") from exc
|
||||
match = next(
|
||||
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
if not match:
|
||||
print(f"Error: Jira account not found for {email}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return match["accountId"]
|
||||
|
||||
|
||||
# ── Tempo worklogs ────────────────────────────────────────────────────────────
|
||||
|
||||
_PROJECT_CODE_RE = re.compile(r"\b([A-Z][A-Z0-9]{1,9})-\d+\b")
|
||||
|
||||
|
||||
def load_jira_ignore(path: Path) -> set[str]:
|
||||
"""Load issue keys to ignore from a markdown table in jira_ignore.md."""
|
||||
if not path.exists():
|
||||
return set()
|
||||
ignored: set[str] = set()
|
||||
in_table = False
|
||||
header_seen = False
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not (line.startswith("|") and line.endswith("|")):
|
||||
in_table = False
|
||||
header_seen = False
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip("|").split("|")]
|
||||
if not header_seen:
|
||||
# First row is header or separator
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
header_seen = True
|
||||
in_table = True
|
||||
continue
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells if c):
|
||||
continue # separator row
|
||||
if in_table and cells and cells[0]:
|
||||
ignored.add(cells[0].strip().upper())
|
||||
return ignored
|
||||
|
||||
|
||||
def extract_project_code(issue_key: str) -> str | None:
|
||||
"""Extract project code from a Jira issue key, e.g. 'IAA' from 'IAA-42'."""
|
||||
m = _PROJECT_CODE_RE.match(issue_key.strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def fetch_worklogs(account_id: str, start: date, end: date,
|
||||
token: str, base_url: str = "https://api.tempo.io/4") -> list[dict]:
|
||||
"""Return all Tempo worklog entries for the account in [start, end]."""
|
||||
results: list[dict] = []
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = (
|
||||
f"{base_url}/worklogs/user/{account_id}"
|
||||
f"?from={start}&to={end}&limit=1000"
|
||||
)
|
||||
while next_url:
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print("Error: Tempo API returned 401. Check TEMPO_API_TOKEN.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise RuntimeError(f"Tempo API {exc.code}: {body[:200]}") from exc
|
||||
results.extend(data.get("results", []))
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
return results
|
||||
|
||||
|
||||
def resolve_issue_keys(worklogs: list[dict], jira_email: str,
|
||||
jira_token: str) -> dict[int, str]:
|
||||
"""
|
||||
Resolve Jira issue IDs → keys by calling each issue's self URL directly.
|
||||
Uses the self URL already present in each Tempo worklog entry.
|
||||
"""
|
||||
creds = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {creds}", "Accept": "application/json"}
|
||||
seen: dict[int, str] = {}
|
||||
for wl in worklogs:
|
||||
issue = wl.get("issue", {})
|
||||
issue_id = issue.get("id")
|
||||
if not issue_id or int(issue_id) in seen:
|
||||
continue
|
||||
self_url = issue.get("self", "")
|
||||
if not self_url:
|
||||
continue
|
||||
# Swap api/2 → api/3 if needed; append fields filter
|
||||
url = re.sub(r"/rest/api/\d+/", "/rest/api/3/", self_url) + "?fields=key"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read())
|
||||
seen[int(issue_id)] = data.get("key", "")
|
||||
except Exception as exc:
|
||||
print(f"Warning: could not resolve issue {issue_id}: {exc}", file=sys.stderr)
|
||||
return seen
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [n for n, v in [
|
||||
("TEMPO_API_TOKEN", tempo_token), ("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email), ("JIRA_API_TOKEN", jira_token),
|
||||
] if not v]
|
||||
if missing:
|
||||
print(f"Error: missing credentials: {', '.join(missing)}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
start, end = parse_month(args.month)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load ignore list
|
||||
ignore_path = Path.cwd() / "jira_ignore.md"
|
||||
ignored_keys = load_jira_ignore(ignore_path)
|
||||
if ignored_keys:
|
||||
print(f"Ignoring issues: {', '.join(sorted(ignored_keys))}")
|
||||
|
||||
print(f"Month: {args.month} ({start} – {end})")
|
||||
print(f"User: {jira_email}")
|
||||
|
||||
# Resolve account ID
|
||||
print("Resolving Jira account...")
|
||||
account_id = resolve_account_id(jira_email, jira_base_url, jira_email, jira_token)
|
||||
print(f" {jira_email} → {account_id}")
|
||||
|
||||
# Working days (Polish calendar)
|
||||
print("Fetching public holidays...")
|
||||
holidays = get_public_holidays("PL", start.year)
|
||||
wdays = working_days(start, end, holidays)
|
||||
expected_hours = len(wdays) * 8.0
|
||||
print(f" Working days: {len(wdays)} Expected hours: {expected_hours:.0f}h")
|
||||
|
||||
# Fetch Tempo worklogs
|
||||
print("Fetching Tempo worklogs...")
|
||||
worklogs = fetch_worklogs(account_id, start, end, tempo_token)
|
||||
print(f" Worklogs fetched: {len(worklogs)}")
|
||||
|
||||
# Resolve issue IDs → keys via each issue's self URL (Tempo v4 omits the key)
|
||||
unique_ids = {int(wl["issue"]["id"]) for wl in worklogs if wl.get("issue", {}).get("id")}
|
||||
issue_key_map: dict[int, str] = {}
|
||||
if unique_ids:
|
||||
print(f" Resolving {len(unique_ids)} unique issue key(s) from Jira...")
|
||||
issue_key_map = resolve_issue_keys(worklogs, jira_email, jira_token)
|
||||
print(f" Resolved: {len(issue_key_map)}")
|
||||
|
||||
# Group hours by project code and by date, skipping ignored issues
|
||||
project_hours: dict[str, float] = {}
|
||||
hours_by_date: dict[str, float] = {}
|
||||
unmatched_hours = 0.0
|
||||
ignored_hours = 0.0
|
||||
|
||||
for wl in worklogs:
|
||||
issue_id = int(wl.get("issue", {}).get("id", 0))
|
||||
issue_key = issue_key_map.get(issue_id, "")
|
||||
hours = wl.get("timeSpentSeconds", 0) / 3600
|
||||
wl_date = wl.get("startDate", "")
|
||||
|
||||
if issue_key.upper() in ignored_keys:
|
||||
ignored_hours += hours
|
||||
# Still count toward date totals so missing-day detection stays accurate
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
continue
|
||||
|
||||
code = extract_project_code(issue_key)
|
||||
if code:
|
||||
project_hours[code] = project_hours.get(code, 0.0) + hours
|
||||
else:
|
||||
unmatched_hours += hours
|
||||
if wl_date:
|
||||
hours_by_date[wl_date] = hours_by_date.get(wl_date, 0.0) + hours
|
||||
|
||||
# Sort projects by hours descending
|
||||
projects = sorted(
|
||||
[{"code": k, "hours": round(v, 2)} for k, v in project_hours.items()],
|
||||
key=lambda x: x["hours"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
total_logged = round(sum(project_hours.values()) + unmatched_hours + ignored_hours, 2)
|
||||
total_billable = round(sum(project_hours.values()) + unmatched_hours, 2)
|
||||
missing_hours = round(max(0.0, expected_hours - total_logged), 2)
|
||||
|
||||
# Find working days with no logged time
|
||||
days_missing = [
|
||||
d.isoformat() for d in wdays
|
||||
if hours_by_date.get(d.isoformat(), 0.0) == 0.0
|
||||
]
|
||||
|
||||
output = {
|
||||
"month": args.month,
|
||||
"user_email": jira_email,
|
||||
"account_id": account_id,
|
||||
"working_days": len(wdays),
|
||||
"expected_hours": expected_hours,
|
||||
"total_logged_hours": total_logged,
|
||||
"total_billable_hours": total_billable,
|
||||
"ignored_hours": round(ignored_hours, 2),
|
||||
"ignored_keys": sorted(ignored_keys),
|
||||
"missing_hours": missing_hours,
|
||||
"unmatched_hours": round(unmatched_hours, 2),
|
||||
"days_with_no_hours": days_missing,
|
||||
"projects": projects,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / f"invoice_prep_{args.month}.json"
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
# Print quick summary
|
||||
print(f"\nProjects:")
|
||||
for p in projects:
|
||||
print(f" {p['code']:12s} {p['hours']:.2f}h")
|
||||
if unmatched_hours:
|
||||
print(f" (no project) {unmatched_hours:.2f}h")
|
||||
if ignored_hours:
|
||||
print(f" (ignored) {ignored_hours:.2f}h ({', '.join(sorted(ignored_keys))})")
|
||||
print(f"\nBillable total: {total_billable:.2f}h")
|
||||
print(f"Total logged: {total_logged:.2f}h / {expected_hours:.0f}h expected")
|
||||
if missing_hours > 0:
|
||||
print(f"⚠ Missing: {missing_hours:.2f}h ({len(days_missing)} day(s) with no time logged)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate invoice line items from Tempo worklogs."
|
||||
)
|
||||
parser.add_argument("--month", required=True, help="Month to check: YYYY-MM")
|
||||
parser.add_argument("--output-dir", required=True,
|
||||
help="Directory for invoice_prep_YYYY-MM.json")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
15
.agents/skills/timesheet-checker/.env.example
Normal file
15
.agents/skills/timesheet-checker/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Timesheet Checker — credentials template
|
||||
# Copy this file to .env and fill in your values.
|
||||
# .env is gitignored and never committed.
|
||||
|
||||
# Tempo API token — generate at: Tempo → Settings → API Integration
|
||||
TEMPO_API_TOKEN=
|
||||
|
||||
# Your Atlassian / Jira Cloud URL
|
||||
JIRA_BASE_URL=https://your-company.atlassian.net
|
||||
|
||||
# The email address you use to log into Jira
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
|
||||
# Jira personal API token — generate at: https://id.atlassian.com → Security → API tokens
|
||||
JIRA_API_TOKEN=
|
||||
1
.agents/skills/timesheet-checker/.gitignore
vendored
Normal file
1
.agents/skills/timesheet-checker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
209
.agents/skills/timesheet-checker/README.md
Normal file
209
.agents/skills/timesheet-checker/README.md
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
# Timesheet Checker
|
||||
|
||||
Checks whether employees have completed their timesheets for a given period by pulling Tempo worklogs. Compares against each employee's country calendar (public holidays fetched from date.nager.at) and a configurable minimum of hours per day. Produces a completion report ranked by missing hours.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The skill needs four credentials. The easiest setup is a `.env` file — copy the template and fill it in once:
|
||||
|
||||
macOS / Linux:
|
||||
```bash
|
||||
cp .claude/skills/timesheet-checker/.env.example .claude/skills/timesheet-checker/.env
|
||||
```
|
||||
Windows (PowerShell):
|
||||
```powershell
|
||||
Copy-Item .claude\skills\timesheet-checker\.env.example .claude\skills\timesheet-checker\.env
|
||||
```
|
||||
Or copy the file manually in Explorer / Finder — rename `.env.example` to `.env` in the skill folder.
|
||||
|
||||
Then open `.env` and fill in your values:
|
||||
|
||||
```
|
||||
TEMPO_API_TOKEN=your_tempo_token
|
||||
JIRA_BASE_URL=https://your-company.atlassian.net
|
||||
JIRA_EMAIL=your.email@company.com
|
||||
JIRA_API_TOKEN=your_jira_token
|
||||
```
|
||||
|
||||
The script looks for `.env` in the **working directory first**, then the skill folder as a fallback. This means you can also place a `.env` in your project root if you prefer one file for all skills.
|
||||
|
||||
Environment variables set via `export` always take priority over `.env` values, so both approaches work side-by-side.
|
||||
|
||||
The `.env` file is gitignored — credentials are never committed.
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
|
||||
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
|
||||
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
|
||||
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
|
||||
|
||||
If any credential is missing from both `.env` and the environment, the script stops with a clear error listing exactly what is needed.
|
||||
|
||||
---
|
||||
|
||||
## How to use in Claude Code
|
||||
|
||||
Claude Code runs as a CLI, desktop app, web app (claude.ai/code), or IDE extension. **Claude Code runs the full pipeline automatically — no manual script steps.**
|
||||
|
||||
**Install** — copy the skill folder into your project's Claude skills directory:
|
||||
|
||||
macOS / Linux:
|
||||
```bash
|
||||
cp -r src/timesheet-checker .claude/skills/
|
||||
```
|
||||
Windows (PowerShell):
|
||||
```powershell
|
||||
Copy-Item -Recurse -Path src\timesheet-checker -Destination .claude\skills\
|
||||
```
|
||||
|
||||
**Use:**
|
||||
Just describe what you want — Claude will pick up the skill automatically:
|
||||
```
|
||||
Check if timesheets are complete for last week
|
||||
```
|
||||
```
|
||||
Timesheet completion for last month
|
||||
```
|
||||
```
|
||||
Check logged hours for 2026-06-01 to 2026-06-30
|
||||
```
|
||||
|
||||
Claude fetches Polish public holidays, pulls Tempo worklogs, computes completion per employee, generates the report, and shows you the results. Output files land in `./timesheet-output/`.
|
||||
|
||||
---
|
||||
|
||||
## How to use in the Claude.ai app
|
||||
|
||||
> Full automation (no manual script running) requires Claude Code. In the Claude.ai app, the Python steps must be run locally.
|
||||
|
||||
**Set up once (Project approach):**
|
||||
1. Create a new Project in Claude.ai.
|
||||
2. In **Project Instructions**, paste the full contents of `SKILL.md`.
|
||||
3. Upload `references/employees.md` and `references/output-format.md` to the Project knowledge.
|
||||
|
||||
**Each time you run:**
|
||||
1. Set all four credentials in your shell, then run the pre-processor locally:
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/preprocess.py \
|
||||
--period last-week \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
The script will use `./employees.md` if it exists, otherwise `$SKILL_DIR/references/employees.md`.
|
||||
2. Upload `timesheet_data.json` to the Project conversation.
|
||||
3. Ask Claude to summarise and present the results.
|
||||
4. Run the post-processor locally to generate the formatted report:
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/postprocess.py \
|
||||
--data ./timesheet-output/timesheet_data.json \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
**Without Projects:** Paste the contents of `SKILL.md` at the start of a new conversation, attach the two reference files, then follow the same steps.
|
||||
|
||||
---
|
||||
|
||||
## What is included
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `SKILL.md` | Orchestrating skill — instructions for Claude |
|
||||
| `references/employees.md` | Editable list of employees and their Tempo account IDs |
|
||||
| `references/output-format.md` | Structure of `timesheet_data.json` and the final report |
|
||||
| `scripts/preprocess.py` | Fetches Tempo worklogs + Polish holidays, computes completion |
|
||||
| `scripts/postprocess.py` | Formats `timesheet_data.json` into the markdown report |
|
||||
| `sample/employees_sample.md` | Sample employees file with placeholder account IDs |
|
||||
|
||||
## Outputs (in `./timesheet-output/`)
|
||||
|
||||
| File | Description |
|
||||
|---|---|
|
||||
| `timesheet_data.json` | Computed stats per employee + period + summary |
|
||||
| `timesheet_report.md` | Manager-readable report ranked by missing hours |
|
||||
|
||||
---
|
||||
|
||||
## Configuring employees
|
||||
|
||||
The script looks for the employees file in this order:
|
||||
|
||||
1. `employees.md` in the **current working directory** — place it here for a project-specific list
|
||||
2. `references/employees.md` in the **skill folder** — the default shipped with the skill
|
||||
|
||||
To use a project-specific list, copy the sample and edit it:
|
||||
```bash
|
||||
cp .claude/skills/timesheet-checker/sample/employees_sample.md ./employees.md
|
||||
```
|
||||
|
||||
The file has two tables:
|
||||
|
||||
**Defaults** — applies to every employee unless overridden:
|
||||
|
||||
| setting | value |
|
||||
|---|---|
|
||||
| `country` | ISO 3166-1 alpha-2 country code (e.g. `PL`, `GB`, `DE`). Used for public holiday lookup. |
|
||||
| `expected_hours_per_day` | Minimum hours per working day |
|
||||
|
||||
**Employees** — one row per person. Leave any column blank to inherit the default.
|
||||
|
||||
| Column | What it controls |
|
||||
|---|---|
|
||||
| `name` | Display name in the report |
|
||||
| `email` | Work email address (used to look up the Jira account ID automatically) |
|
||||
| `country` | Override the default country for this employee |
|
||||
| `expected_hours_per_day` | Override the default hours for this employee |
|
||||
| `start_date` | First working day — leave blank if employed for the full period |
|
||||
|
||||
---
|
||||
|
||||
## Period formats
|
||||
|
||||
| Input | Meaning | End date |
|
||||
|---|---|---|
|
||||
| `last-week` | Previous Monday–Friday | Last Friday |
|
||||
| `last-month` | Previous calendar month | Last day of that month |
|
||||
| `last-year` | Previous calendar year | 31 December |
|
||||
| `yesterday` | Yesterday only | Yesterday |
|
||||
| `current-week` | Monday of this week to yesterday | Yesterday |
|
||||
| `current-month` | 1st of this month to yesterday | Yesterday |
|
||||
| `current-year` | 1st January of this year to yesterday | Yesterday |
|
||||
| `YYYY-MM-DD:YYYY-MM-DD` | Explicit date range | As specified |
|
||||
|
||||
For `current-*` periods the current in-progress day is always excluded — end date is yesterday. If today is the first day of the period (e.g. `current-week` on a Monday), the period contains no past working days and the report will show 0 expected hours.
|
||||
|
||||
In Claude Code, you can also use natural language ("last week", "this month", "yesterday", "June 2026") and Claude converts it automatically.
|
||||
|
||||
---
|
||||
|
||||
## How working days are calculated
|
||||
|
||||
The script fetches Polish public holidays from the [date.nager.at](https://date.nager.at) public API (no authentication needed). It then excludes weekends and those holidays from the period. This runs live each time — holiday data is never hardcoded.
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
This skill connects to **Tempo Cloud** (`api.tempo.io/4`) by default. For self-hosted Tempo (Jira Data Center), pass the correct base URL:
|
||||
|
||||
```bash
|
||||
python3 $SKILL_DIR/scripts/preprocess.py \
|
||||
--tempo-base-url https://your-jira.company.com/rest/tempo-timesheets/4 \
|
||||
...
|
||||
```
|
||||
|
||||
Or in Claude Code:
|
||||
```
|
||||
Check timesheets for last week, Tempo is at https://jira.company.com/rest/tempo-timesheets/4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Limits
|
||||
|
||||
- Requires `TEMPO_API_TOKEN` — the script stops immediately with a clear error if it is missing.
|
||||
- Employees with 0 hours may be on leave; the tool does not distinguish between missing logs and approved absence. Review these manually.
|
||||
- If the period end date is today or in the future, the period is still open and data will be partial.
|
||||
- Polish holidays only. If your team uses a different country calendar, the `get_polish_holidays` function in `preprocess.py` needs updating.
|
||||
- Tempo Cloud API v4 only. Tempo Server / Data Center may require a different base URL and authentication method.
|
||||
115
.agents/skills/timesheet-checker/SKILL.md
Normal file
115
.agents/skills/timesheet-checker/SKILL.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
name: timesheet-checker
|
||||
description: >
|
||||
Check timesheet completion for a given period by pulling Tempo worklogs for all reporters
|
||||
found in Tempo (not just those in employees.md). Flags anyone missing from employees.md
|
||||
and asks the user to add them. Requires TEMPO_API_TOKEN. Checks against the Polish working
|
||||
calendar and 8h/day minimum. Produces a completion report ranked by missing hours.
|
||||
TRIGGER this skill automatically whenever the user mentions checking, reviewing, or auditing
|
||||
timesheets or timesheet completion — especially when they mention a period like last week,
|
||||
last month, or a date range. Trigger phrases include: "check timesheets", "timesheet completion",
|
||||
"review timesheets", "who hasn't logged hours", "timesheet audit", "check logged hours",
|
||||
"hours reported", or any message asking about timesheet status or missing hours.
|
||||
---
|
||||
|
||||
# Timesheet Checker
|
||||
|
||||
Use this skill when someone needs to know if employees have completed their timesheets for a given period. It pulls worklogs from Tempo, computes each person's completion against the Polish working calendar, and ranks employees by missing hours.
|
||||
|
||||
**In Claude Code, you run the full pipeline automatically.** The user provides a period — you handle everything else.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This skill requires four credentials. The easiest way to provide them is a `.env` file — copy `.env.example` in the skill folder and fill in the values:
|
||||
|
||||
```
|
||||
cp {SKILL_DIR}/.env.example {SKILL_DIR}/.env
|
||||
# then edit {SKILL_DIR}/.env
|
||||
```
|
||||
|
||||
Or place the `.env` file in the working directory (where you run Claude Code) — that takes priority over the skill folder.
|
||||
|
||||
If the user prefers environment variables they can still use `export`, but the `.env` file removes the need to do that every session.
|
||||
|
||||
The four credentials:
|
||||
|
||||
| Variable | What it is | Where to get it |
|
||||
|---|---|---|
|
||||
| `TEMPO_API_TOKEN` | Tempo read token | Tempo → Settings → API Integration |
|
||||
| `JIRA_BASE_URL` | Your Atlassian URL | The URL you use to open Jira |
|
||||
| `JIRA_EMAIL` | Your Jira login email | Your Atlassian account email |
|
||||
| `JIRA_API_TOKEN` | Jira personal API token | [id.atlassian.com](https://id.atlassian.com) → Security → API tokens |
|
||||
|
||||
`JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` are used to resolve employee email addresses to Jira account IDs before querying Tempo. `TEMPO_API_TOKEN` is used to pull the actual worklogs.
|
||||
|
||||
## Setup
|
||||
|
||||
Determine `SKILL_DIR` from the location of this file. Set the output directory to `./timesheet-output/` in the current working directory. Create it if it does not exist.
|
||||
|
||||
## Load First
|
||||
|
||||
Read these reference files:
|
||||
|
||||
1. The employees config — check `./employees.md` in the current working directory first; if not present, fall back to `{SKILL_DIR}/references/employees.md`. Tell the user which one is being used.
|
||||
2. `{SKILL_DIR}/references/output-format.md` — the structure of `timesheet_data.json` and the final report.
|
||||
|
||||
## Extracting the period
|
||||
|
||||
The user may express the period in various ways. Convert to the CLI format before running:
|
||||
|
||||
| User says | CLI value | End date |
|
||||
|---|---|---|
|
||||
| "last week" | `last-week` | Last Friday |
|
||||
| "last month" | `last-month` | Last day of previous month |
|
||||
| "last year" | `last-year` | 31 December of previous year |
|
||||
| "yesterday" | `yesterday` | Yesterday |
|
||||
| "this week", "current week" | `current-week` | Yesterday |
|
||||
| "this month", "current month" | `current-month` | Yesterday |
|
||||
| "this year", "current year" | `current-year` | Yesterday |
|
||||
| "June", "June 2026" | `2026-06-01:2026-06-30` | Explicit |
|
||||
| Explicit dates | `YYYY-MM-DD:YYYY-MM-DD` | Explicit |
|
||||
|
||||
For `current-*` periods the end date is always yesterday — the current in-progress day is never included. If a user asks for "current week" on a Monday (the week only started today), there are no past working days yet; note this to the user.
|
||||
|
||||
If the user has not specified a period, ask: "Which period should I check? (e.g. last week, this month, yesterday, or a date range)"
|
||||
|
||||
## Workflow
|
||||
|
||||
**Step 1 - Fetch and compute.** Run this command with bash:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/preprocess.py \
|
||||
--period {period} \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This fetches Polish public holidays from date.nager.at, computes working days, pulls all Tempo worklogs for the period, and writes `timesheet_data.json` to `./timesheet-output/`.
|
||||
|
||||
**Step 2 - Interpret.** Read `./timesheet-output/timesheet_data.json`. Note:
|
||||
- Any employee with `reported_hours: 0` — this may mean vacation, sick leave, or forgotten logging. Mention these explicitly.
|
||||
- If the period end date is today or in the future, the period is still open — note this so the user knows the data is partial.
|
||||
- The overall completion percentage and how many employees have gaps.
|
||||
- **Unknown reporters:** If `unknown_reporters` is non-empty, show the user a table of those people (name, email, hours logged) and ask them to add the missing employees to `employees.md`. Then continue with Steps 3–4 using only the known employees, noting that the report excludes the unknown reporters until they are added.
|
||||
|
||||
**Step 3 - Generate report.** Run this command with bash:
|
||||
|
||||
```bash
|
||||
python3 {SKILL_DIR}/scripts/postprocess.py \
|
||||
--data ./timesheet-output/timesheet_data.json \
|
||||
--output-dir ./timesheet-output
|
||||
```
|
||||
|
||||
This writes `timesheet_report.md`.
|
||||
|
||||
**Step 4 - Present results.** Show the user:
|
||||
- The summary block (expected / reported / overall completion %).
|
||||
- The full employee table from the report.
|
||||
- Any notable observations from Step 2.
|
||||
- "Full report saved to `./timesheet-output/timesheet_report.md`."
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never invent or estimate hours. All data comes from Tempo.
|
||||
- Working days are computed from the Polish public holidays API — do not hardcode holiday dates.
|
||||
- If `TEMPO_API_TOKEN` is not set, stop and ask the user for it. Do not proceed without it.
|
||||
- If any script step fails, show the error and stop. Do not proceed with partial output.
|
||||
22
.agents/skills/timesheet-checker/references/employees.md
Normal file
22
.agents/skills/timesheet-checker/references/employees.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Employees
|
||||
|
||||
## Defaults
|
||||
|
||||
These values apply to every employee unless overridden in the Employees table.
|
||||
|
||||
| setting | value |
|
||||
| --- | --- |
|
||||
| country | PL |
|
||||
| expected_hours_per_day | 8 |
|
||||
|
||||
## Employees
|
||||
|
||||
Add a row for each employee. Use their work email address — the script looks up the Jira account ID automatically. Leave any column blank to inherit the default.
|
||||
|
||||
`country` is an ISO 3166-1 alpha-2 code (e.g. `PL`, `GB`, `DE`, `FR`). Public holidays for that country are fetched from date.nager.at. `start_date` is the employee's first working day — leave blank if they were employed for the full period.
|
||||
|
||||
| name | email | country | expected_hours_per_day | start_date |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Jan Kowalski | jan.kowalski@company.com | | | |
|
||||
| Anna Nowak | anna.nowak@company.com | | | |
|
||||
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |
|
||||
49
.agents/skills/timesheet-checker/references/output-format.md
Normal file
49
.agents/skills/timesheet-checker/references/output-format.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Output Format
|
||||
|
||||
## timesheet_data.json (written by preprocess.py)
|
||||
|
||||
Machine-readable intermediate file with all fetched and computed data.
|
||||
|
||||
```json
|
||||
{
|
||||
"period": {
|
||||
"start": "2026-06-16",
|
||||
"end": "2026-06-20",
|
||||
"label": "last week",
|
||||
"working_days": ["2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"],
|
||||
"working_day_count": 5
|
||||
},
|
||||
"summary": {
|
||||
"total_employees": 3,
|
||||
"total_expected_hours": 120,
|
||||
"total_reported_hours": 96.5,
|
||||
"overall_completion_pct": 80.4,
|
||||
"employees_fully_complete": 1,
|
||||
"employees_with_gaps": 2
|
||||
},
|
||||
"employees": [
|
||||
{
|
||||
"name": "Jan Kowalski",
|
||||
"account_id": "557058:aaaa...",
|
||||
"expected_hours": 40,
|
||||
"reported_hours": 32.5,
|
||||
"missing_hours": 7.5,
|
||||
"completion_pct": 81.3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `employees` is sorted by `missing_hours` descending — the employee furthest behind appears first.
|
||||
- `completion_pct` = `reported_hours / expected_hours × 100`. Can exceed 100 if overtime was logged.
|
||||
- `missing_hours` = `max(0, expected_hours - reported_hours)`.
|
||||
- Working days exclude weekends and Polish public holidays fetched from date.nager.at.
|
||||
|
||||
## timesheet_report.md (written by postprocess.py)
|
||||
|
||||
Manager-readable markdown report containing:
|
||||
- Period details (dates, working day count)
|
||||
- Summary block: expected total, reported total, overall completion %
|
||||
- Employee table ranked by missing hours (most behind first)
|
||||
- Completion badges: ✓ complete, ⚠ below 75%, ⚠⚠ below 50%
|
||||
20
.agents/skills/timesheet-checker/sample/employees_sample.md
Normal file
20
.agents/skills/timesheet-checker/sample/employees_sample.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Employees (sample)
|
||||
|
||||
## Defaults
|
||||
|
||||
| setting | value |
|
||||
| --- | --- |
|
||||
| country | PL |
|
||||
| expected_hours_per_day | 8 |
|
||||
|
||||
## Employees
|
||||
|
||||
Replace email addresses with your team's real work emails.
|
||||
|
||||
| name | email | country | expected_hours_per_day | start_date |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Jan Kowalski | jan.kowalski@company.com | | | |
|
||||
| Anna Nowak | anna.nowak@company.com | | | |
|
||||
| Piotr Wiśniewski | piotr.wisniewski@company.com | | | 2026-05-15 |
|
||||
| Marta Jabłońska | marta.jablonska@company.com | | | |
|
||||
| Tom Smith | tom.smith@company.com | GB | | |
|
||||
156
.agents/skills/timesheet-checker/scripts/postprocess.py
Normal file
156
.agents/skills/timesheet-checker/scripts/postprocess.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Format timesheet completion data into a markdown report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def fmt_h(hours: float) -> str:
|
||||
"""Format hours with one decimal place, dropping .0 for whole numbers."""
|
||||
if hours == int(hours):
|
||||
return f"{int(hours)}h"
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def completion_badge(pct: float) -> str:
|
||||
if pct >= 100:
|
||||
return "✓"
|
||||
if pct >= 75:
|
||||
return ""
|
||||
if pct >= 50:
|
||||
return "⚠"
|
||||
return "⚠⚠"
|
||||
|
||||
|
||||
def build_report(data: dict[str, Any]) -> str:
|
||||
period = data["period"]
|
||||
summary = data["summary"]
|
||||
employees = data["employees"]
|
||||
|
||||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
n = summary["total_employees"]
|
||||
per_person = fmt_h(summary["total_expected_hours"] / n) if n else "0h"
|
||||
|
||||
wdays_by_country: dict[str, int] = period.get("working_days_by_country", {})
|
||||
if len(wdays_by_country) == 1:
|
||||
country, wdays = next(iter(wdays_by_country.items()))
|
||||
wdays_line = f"Working days: {wdays} ({country} calendar, public holidays excluded)"
|
||||
else:
|
||||
parts = ", ".join(f"{c}: {d}d" for c, d in sorted(wdays_by_country.items()))
|
||||
wdays_line = f"Working days by country: {parts} (public holidays excluded)"
|
||||
|
||||
lines = [
|
||||
"# Timesheet Completion Report",
|
||||
"",
|
||||
f"Generated: {generated_at}",
|
||||
f"Period: {period['label'].capitalize()} ({period['start']} to {period['end']})",
|
||||
wdays_line,
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- Expected: **{fmt_h(summary['total_expected_hours'])}** ({n} employees × {per_person} avg)",
|
||||
f"- Reported: **{fmt_h(summary['total_reported_hours'])}**",
|
||||
f"- Overall completion: **{summary['overall_completion_pct']}%**",
|
||||
f"- Fully complete: {summary['employees_fully_complete']} of {n} employees",
|
||||
f"- With gaps: {summary['employees_with_gaps']} employees",
|
||||
"",
|
||||
"## Employees (ranked by missing hours)",
|
||||
"",
|
||||
"| Employee | Expected | Reported | Completion | Missing |",
|
||||
"| --- | ---: | ---: | ---: | ---: |",
|
||||
]
|
||||
|
||||
# Determine the most common working-day count to detect deviations
|
||||
max_wdays = max(wdays_by_country.values()) if wdays_by_country else 0
|
||||
for emp in employees:
|
||||
badge = completion_badge(emp["completion_pct"])
|
||||
missing = fmt_h(emp["missing_hours"]) if emp["missing_hours"] > 0 else "—"
|
||||
pct_str = f"{emp['completion_pct']}% {badge}".strip()
|
||||
name = emp["name"]
|
||||
notes = []
|
||||
eff = emp.get("effective_working_days", max_wdays)
|
||||
if emp.get("start_date") and eff < max_wdays:
|
||||
notes.append(f"from {emp['start_date']}")
|
||||
if emp.get("termination_date") and eff < max_wdays:
|
||||
notes.append(f"until {emp['termination_date']}")
|
||||
if len(wdays_by_country) > 1:
|
||||
notes.append(emp.get("country", ""))
|
||||
if notes:
|
||||
name += f" _({', '.join(n for n in notes if n)})_"
|
||||
lines.append(
|
||||
f"| {name} | {fmt_h(emp['expected_hours'])} "
|
||||
f"| {fmt_h(emp['reported_hours'])} | {pct_str} | {missing} |"
|
||||
)
|
||||
|
||||
out_of_range = [
|
||||
emp for emp in employees
|
||||
if emp.get("hours_before_start", 0) > 0 or emp.get("hours_after_termination", 0) > 0
|
||||
]
|
||||
if out_of_range:
|
||||
lines += [
|
||||
"",
|
||||
"## Hours logged outside employment period",
|
||||
"",
|
||||
"These hours are excluded from the completion calculation above.",
|
||||
"",
|
||||
"| Employee | Before start date | After termination date |",
|
||||
"| --- | ---: | ---: |",
|
||||
]
|
||||
for emp in out_of_range:
|
||||
before = fmt_h(emp["hours_before_start"]) if emp.get("hours_before_start", 0) > 0 else "—"
|
||||
after = fmt_h(emp["hours_after_termination"]) if emp.get("hours_after_termination", 0) > 0 else "—"
|
||||
lines.append(f"| {emp['name']} | {before} | {after} |")
|
||||
|
||||
countries_str = ", ".join(sorted(wdays_by_country.keys()))
|
||||
lines += [
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"_Working days source: [date.nager.at](https://date.nager.at) ({countries_str})._",
|
||||
"_Hours source: Tempo._",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
data_path = Path(args.data)
|
||||
if not data_path.exists():
|
||||
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
data = json.loads(data_path.read_text(encoding="utf-8"))
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
report = build_report(data)
|
||||
report_path = output_dir / "timesheet_report.md"
|
||||
report_path.write_text(report, encoding="utf-8")
|
||||
print(f"Wrote {report_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Format timesheet completion data into a markdown report."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data",
|
||||
required=True,
|
||||
help="Path to timesheet_data.json produced by preprocess.py.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
required=True,
|
||||
help="Directory for timesheet_report.md.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
649
.agents/skills/timesheet-checker/scripts/preprocess.py
Normal file
649
.agents/skills/timesheet-checker/scripts/preprocess.py
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch Tempo worklogs and compute timesheet completion for a given period."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── .env loading ─────────────────────────────────────────────────────────────
|
||||
|
||||
def load_dotenv(*paths: Path) -> Path | None:
|
||||
"""Load the first .env file found from the given paths.
|
||||
|
||||
Sets missing keys into os.environ (existing env vars take priority).
|
||||
Returns the path that was loaded, or None if none were found.
|
||||
"""
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = value
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def clean_text(value: Any) -> str:
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
|
||||
def normalize_key(value: Any) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "_", clean_text(value).lower()).strip("_")
|
||||
|
||||
|
||||
# ── Period parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_period(period_str: str, today: date | None = None) -> tuple[date, date, str]:
|
||||
"""Return (start, end, human_label) for the given period string.
|
||||
|
||||
Supported keywords:
|
||||
last-week Previous Monday–Friday
|
||||
last-month Previous calendar month
|
||||
last-year Previous calendar year
|
||||
yesterday Yesterday only
|
||||
current-week Monday of this week to yesterday
|
||||
current-month 1st of this month to yesterday
|
||||
current-year 1st January of this year to yesterday
|
||||
YYYY-MM-DD:YYYY-MM-DD Explicit date range
|
||||
|
||||
For current-* periods the end date is always yesterday so the current
|
||||
in-progress day is excluded. If yesterday falls before the period start
|
||||
(e.g. calling current-week on a Monday), end < start and the period
|
||||
will contain zero working days — a warning is printed by run().
|
||||
"""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
yesterday = today - timedelta(days=1)
|
||||
s = period_str.strip().lower()
|
||||
|
||||
if s == "last-week":
|
||||
last_monday = today - timedelta(days=today.weekday() + 7)
|
||||
return last_monday, last_monday + timedelta(days=4), "last week"
|
||||
if s == "last-month":
|
||||
end = today.replace(day=1) - timedelta(days=1)
|
||||
return end.replace(day=1), end, "last month"
|
||||
if s == "last-year":
|
||||
return date(today.year - 1, 1, 1), date(today.year - 1, 12, 31), "last year"
|
||||
if s == "yesterday":
|
||||
return yesterday, yesterday, "yesterday"
|
||||
if s == "current-week":
|
||||
start = today - timedelta(days=today.weekday())
|
||||
return start, yesterday, "current week to date"
|
||||
if s == "current-month":
|
||||
return today.replace(day=1), yesterday, "current month to date"
|
||||
if s == "current-year":
|
||||
return date(today.year, 1, 1), yesterday, "current year to date"
|
||||
if ":" in s:
|
||||
parts = s.split(":", 1)
|
||||
start = date.fromisoformat(parts[0].strip())
|
||||
end = date.fromisoformat(parts[1].strip())
|
||||
return start, end, f"{start} to {end}"
|
||||
raise ValueError(
|
||||
f"Unknown period '{period_str}'. "
|
||||
f"Use last-week, last-month, last-year, yesterday, "
|
||||
f"current-week, current-month, current-year, or YYYY-MM-DD:YYYY-MM-DD."
|
||||
)
|
||||
|
||||
|
||||
# ── Public holidays ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_public_holidays(country: str, years: set[int]) -> set[date]:
|
||||
"""Fetch public holidays from date.nager.at for the given country and years.
|
||||
|
||||
country is an ISO 3166-1 alpha-2 code (e.g. PL, GB, DE, FR).
|
||||
Returns an empty set with a warning if the country is not supported.
|
||||
"""
|
||||
holidays: set[date] = set()
|
||||
for year in sorted(years):
|
||||
url = f"https://date.nager.at/api/v3/PublicHolidays/{year}/{country.upper()}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "timesheet-checker/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
items = json.loads(resp.read())
|
||||
year_holidays = {date.fromisoformat(item["date"]) for item in items}
|
||||
holidays |= year_holidays
|
||||
print(f" {country.upper()} {year}: {len(year_holidays)} public holidays")
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(
|
||||
f"Warning: country '{country.upper()}' not supported by date.nager.at "
|
||||
f"— no holidays will be excluded for {year}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
|
||||
except urllib.error.URLError as exc:
|
||||
print(f"Warning: could not fetch holidays for {country.upper()} {year}: {exc}", file=sys.stderr)
|
||||
return holidays
|
||||
|
||||
|
||||
def get_working_days(start: date, end: date, holidays: set[date]) -> list[date]:
|
||||
"""Return weekdays (Mon–Fri) in [start, end] that are not public holidays."""
|
||||
result = []
|
||||
current = start
|
||||
while current <= end:
|
||||
if current.weekday() < 5 and current not in holidays:
|
||||
result.append(current)
|
||||
current += timedelta(days=1)
|
||||
return result
|
||||
|
||||
|
||||
# ── Employees config ──────────────────────────────────────────────────────────
|
||||
|
||||
def is_table_row(line: str) -> bool:
|
||||
s = line.strip()
|
||||
return s.startswith("|") and s.endswith("|")
|
||||
|
||||
|
||||
def split_table_row(line: str) -> list[str]:
|
||||
s = line.strip().lstrip("|").rstrip("|")
|
||||
return [c.strip() for c in s.split("|")]
|
||||
|
||||
|
||||
def is_separator_row(cells: list[str]) -> bool:
|
||||
return all(re.fullmatch(r":?-{3,}:?", c.strip()) for c in cells)
|
||||
|
||||
|
||||
def parse_markdown_tables(path: Path) -> list[dict[str, Any]]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
tables: list[dict[str, Any]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if not is_table_row(lines[i]):
|
||||
i += 1
|
||||
continue
|
||||
block: list[str] = []
|
||||
while i < len(lines) and is_table_row(lines[i]):
|
||||
block.append(lines[i])
|
||||
i += 1
|
||||
if len(block) < 3:
|
||||
continue
|
||||
headers = [normalize_key(c) for c in split_table_row(block[0])]
|
||||
if not is_separator_row(split_table_row(block[1])):
|
||||
continue
|
||||
rows = []
|
||||
for line in block[2:]:
|
||||
vals = split_table_row(line)
|
||||
if len(vals) != len(headers):
|
||||
raise ValueError(f"Malformed table row in {path}: {line!r}")
|
||||
rows.append(dict(zip(headers, vals)))
|
||||
tables.append({"headers": headers, "rows": rows})
|
||||
return tables
|
||||
|
||||
|
||||
def find_table(tables: list[dict[str, Any]], required_headers: set[str]) -> list[dict[str, str]]:
|
||||
for t in tables:
|
||||
if required_headers.issubset(set(t["headers"])):
|
||||
return t["rows"]
|
||||
raise ValueError(f"No table with headers {sorted(required_headers)} found in employees file.")
|
||||
|
||||
|
||||
def load_employees(path: Path) -> list[dict[str, Any]]:
|
||||
tables = parse_markdown_tables(path)
|
||||
|
||||
# Load defaults (optional table with setting/value columns).
|
||||
defaults: dict[str, Any] = {"country": "PL", "expected_hours_per_day": 8.0}
|
||||
try:
|
||||
for row in find_table(tables, {"setting", "value"}):
|
||||
key = normalize_key(row["setting"])
|
||||
val = clean_text(row["value"])
|
||||
if not val:
|
||||
continue
|
||||
if key == "country":
|
||||
defaults["country"] = val.upper()
|
||||
elif key == "expected_hours_per_day":
|
||||
try:
|
||||
defaults["expected_hours_per_day"] = float(val)
|
||||
except ValueError:
|
||||
print(f"Warning: invalid default expected_hours_per_day '{val}' — using 8.", file=sys.stderr)
|
||||
except ValueError:
|
||||
pass # no defaults table; built-in defaults apply
|
||||
|
||||
rows = find_table(tables, {"name", "email"})
|
||||
employees = []
|
||||
for row in rows:
|
||||
name = clean_text(row["name"])
|
||||
email = clean_text(row["email"]).lower()
|
||||
if not name or not email:
|
||||
continue
|
||||
|
||||
raw_h = clean_text(row.get("expected_hours_per_day", ""))
|
||||
try:
|
||||
expected_h = float(raw_h) if raw_h else defaults["expected_hours_per_day"]
|
||||
except ValueError:
|
||||
expected_h = defaults["expected_hours_per_day"]
|
||||
|
||||
raw_country = clean_text(row.get("country", "")).upper()
|
||||
country = raw_country if raw_country else defaults["country"]
|
||||
|
||||
raw_start = clean_text(row.get("start_date", ""))
|
||||
start_date: date | None = None
|
||||
if raw_start:
|
||||
try:
|
||||
start_date = date.fromisoformat(raw_start)
|
||||
except ValueError:
|
||||
print(
|
||||
f"Warning: could not parse start_date '{raw_start}' for {name} — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_term = clean_text(row.get("termination_date", ""))
|
||||
termination_date: date | None = None
|
||||
if raw_term:
|
||||
try:
|
||||
termination_date = date.fromisoformat(raw_term)
|
||||
except ValueError:
|
||||
print(
|
||||
f"Warning: could not parse termination_date '{raw_term}' for {name} — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
employees.append({
|
||||
"name": name,
|
||||
"email": email,
|
||||
"expected_hours_per_day": expected_h,
|
||||
"country": country,
|
||||
"start_date": start_date,
|
||||
"termination_date": termination_date,
|
||||
})
|
||||
|
||||
if not employees:
|
||||
raise ValueError(f"No employees found in {path}. Check the table format.")
|
||||
return employees
|
||||
|
||||
|
||||
# ── Jira user lookup ──────────────────────────────────────────────────────────
|
||||
|
||||
def resolve_account_ids(
|
||||
emails: list[str],
|
||||
jira_base_url: str,
|
||||
jira_email: str,
|
||||
jira_token: str,
|
||||
) -> dict[str, str]:
|
||||
"""Return {email: account_id} by searching Jira's user API for each address."""
|
||||
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
|
||||
resolved: dict[str, str] = {}
|
||||
not_found: list[str] = []
|
||||
|
||||
for email in emails:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user/search?query={email}&maxResults=10"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
users = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
print(
|
||||
"Error: Jira API returned 401 Unauthorized.\n"
|
||||
"Check JIRA_EMAIL and JIRA_API_TOKEN (generate at id.atlassian.com).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
print(f"Error: Jira API returned {exc.code} for {email}: {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
match = next(
|
||||
(u for u in users if u.get("emailAddress", "").lower() == email.lower()),
|
||||
None,
|
||||
)
|
||||
if match:
|
||||
resolved[email] = match["accountId"]
|
||||
print(f" {email} → {match['accountId']}")
|
||||
else:
|
||||
not_found.append(email)
|
||||
|
||||
if not_found:
|
||||
print(
|
||||
f"Error: Could not find Jira account for: {', '.join(not_found)}\n"
|
||||
"Check the email addresses in employees.md or your JIRA_BASE_URL.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
# ── Tempo API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_tempo_worklogs(
|
||||
start: date,
|
||||
end: date,
|
||||
token: str,
|
||||
base_url: str,
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""Return {account_id: {date_str: hours}} for ALL reporters in the period.
|
||||
|
||||
Paginates automatically. Date strings are ISO format (YYYY-MM-DD).
|
||||
"""
|
||||
totals: dict[str, dict[str, float]] = {}
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
||||
next_url: str | None = f"{base_url}/worklogs?from={start}&to={end}&limit=1000"
|
||||
page = 0
|
||||
while next_url:
|
||||
page += 1
|
||||
req = urllib.request.Request(next_url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
if exc.code == 401:
|
||||
print(
|
||||
"Error: Tempo API returned 401 Unauthorized. Check your TEMPO_API_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f"Error: Tempo API returned {exc.code}: {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for worklog in data.get("results", []):
|
||||
aid = worklog.get("author", {}).get("accountId", "")
|
||||
date_str = worklog.get("startDate", "")
|
||||
if aid and date_str:
|
||||
if aid not in totals:
|
||||
totals[aid] = {}
|
||||
totals[aid][date_str] = (
|
||||
totals[aid].get(date_str, 0.0) + worklog.get("timeSpentSeconds", 0) / 3600
|
||||
)
|
||||
next_url = data.get("metadata", {}).get("next")
|
||||
print(f"Fetched worklogs (pages: {page})")
|
||||
return totals
|
||||
|
||||
|
||||
def resolve_account_info(
|
||||
account_ids: list[str],
|
||||
jira_base_url: str,
|
||||
jira_email: str,
|
||||
jira_token: str,
|
||||
) -> dict[str, dict]:
|
||||
"""Return {account_id: {display_name, email}} for the given Jira account IDs."""
|
||||
credentials = base64.b64encode(f"{jira_email}:{jira_token}".encode()).decode()
|
||||
headers = {"Authorization": f"Basic {credentials}", "Accept": "application/json"}
|
||||
result: dict[str, dict] = {}
|
||||
for aid in account_ids:
|
||||
url = f"{jira_base_url.rstrip('/')}/rest/api/3/user?accountId={aid}"
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
user = json.loads(resp.read())
|
||||
result[aid] = {
|
||||
"display_name": user.get("displayName", ""),
|
||||
"email": user.get("emailAddress", ""),
|
||||
}
|
||||
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||
result[aid] = {"display_name": f"Unknown ({aid})", "email": ""}
|
||||
return result
|
||||
|
||||
|
||||
# ── Stats computation ─────────────────────────────────────────────────────────
|
||||
|
||||
def compute_stats(
|
||||
employees: list[dict[str, Any]],
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
holidays_by_country: dict[str, set[date]],
|
||||
worklogs: dict[str, dict[str, float]],
|
||||
) -> list[dict[str, Any]]:
|
||||
results = []
|
||||
for emp in employees:
|
||||
holidays = holidays_by_country.get(emp["country"], set())
|
||||
all_working_days = get_working_days(period_start, period_end, holidays)
|
||||
emp_start: date | None = emp.get("start_date")
|
||||
emp_term: date | None = emp.get("termination_date")
|
||||
emp_working_days = all_working_days
|
||||
if emp_start:
|
||||
emp_working_days = [d for d in emp_working_days if d >= emp_start]
|
||||
if emp_term:
|
||||
emp_working_days = [d for d in emp_working_days if d <= emp_term]
|
||||
expected_h = emp["expected_hours_per_day"] * len(emp_working_days)
|
||||
|
||||
# Partition logged hours into in-range vs out-of-range
|
||||
date_logs = worklogs.get(emp["account_id"], {})
|
||||
hours_in_range = 0.0
|
||||
hours_before_start = 0.0
|
||||
hours_after_termination = 0.0
|
||||
for date_str, h in date_logs.items():
|
||||
d = date.fromisoformat(date_str)
|
||||
if emp_start and d < emp_start:
|
||||
hours_before_start += h
|
||||
elif emp_term and d > emp_term:
|
||||
hours_after_termination += h
|
||||
else:
|
||||
hours_in_range += h
|
||||
|
||||
reported_h = round(hours_in_range, 2)
|
||||
hours_before_start = round(hours_before_start, 2)
|
||||
hours_after_termination = round(hours_after_termination, 2)
|
||||
missing_h = round(max(0.0, expected_h - reported_h), 2)
|
||||
pct = round((reported_h / expected_h * 100) if expected_h > 0 else 100.0, 1)
|
||||
results.append({
|
||||
"name": emp["name"],
|
||||
"account_id": emp["account_id"],
|
||||
"country": emp["country"],
|
||||
"start_date": emp_start.isoformat() if emp_start else None,
|
||||
"termination_date": emp_term.isoformat() if emp_term else None,
|
||||
"effective_working_days": len(emp_working_days),
|
||||
"expected_hours": expected_h,
|
||||
"reported_hours": reported_h,
|
||||
"hours_before_start": hours_before_start,
|
||||
"hours_after_termination": hours_after_termination,
|
||||
"missing_hours": missing_h,
|
||||
"completion_pct": pct,
|
||||
})
|
||||
return sorted(results, key=lambda r: r["missing_hours"], reverse=True)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_employees_path(cli_value: str | None) -> Path | None:
|
||||
"""Return the employees file to use, or None if not found.
|
||||
|
||||
Search order:
|
||||
1. Explicit --employees argument (if given)
|
||||
2. employees.md in the current working directory
|
||||
3. references/employees.md in the skill folder (next to this script)
|
||||
"""
|
||||
if cli_value:
|
||||
return Path(cli_value)
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
for candidate in (
|
||||
Path.cwd() / "employees.md",
|
||||
skill_dir / "references" / "employees.md",
|
||||
):
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
# Load .env from working directory first, then skill directory as fallback.
|
||||
# Existing environment variables always take priority over .env values.
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
loaded = load_dotenv(Path.cwd() / ".env", skill_dir / ".env")
|
||||
if loaded:
|
||||
print(f"Loaded credentials from {loaded}")
|
||||
|
||||
tempo_token = args.tempo_token or os.environ.get("TEMPO_API_TOKEN", "")
|
||||
jira_base_url = args.jira_base_url or os.environ.get("JIRA_BASE_URL", "")
|
||||
jira_email = args.jira_email or os.environ.get("JIRA_EMAIL", "")
|
||||
jira_token = args.jira_token or os.environ.get("JIRA_API_TOKEN", "")
|
||||
|
||||
missing = [
|
||||
name for name, val in [
|
||||
("TEMPO_API_TOKEN", tempo_token),
|
||||
("JIRA_BASE_URL", jira_base_url),
|
||||
("JIRA_EMAIL", jira_email),
|
||||
("JIRA_API_TOKEN", jira_token),
|
||||
] if not val
|
||||
]
|
||||
if missing:
|
||||
print(
|
||||
f"Error: missing required credentials: {', '.join(missing)}\n"
|
||||
"Set them as environment variables or pass via CLI flags.\n"
|
||||
"See README.md for how to obtain each credential.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
start, end, label = parse_period(args.period)
|
||||
print(f"Period: {label} ({start} to {end})")
|
||||
|
||||
employees_path = _resolve_employees_path(args.employees)
|
||||
if employees_path is None:
|
||||
skill_dir = Path(__file__).parent.parent
|
||||
print(
|
||||
"Error: No employees.md found.\n"
|
||||
f"Looked in:\n"
|
||||
f" {Path.cwd() / 'employees.md'}\n"
|
||||
f" {skill_dir / 'references' / 'employees.md'}\n"
|
||||
"Create one in your project directory or pass --employees PATH.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
employees = load_employees(employees_path)
|
||||
print(f"Employees loaded: {len(employees)} (from {employees_path})")
|
||||
|
||||
countries = {e["country"] for e in employees}
|
||||
years = {start.year, end.year}
|
||||
print(f"Fetching public holidays from date.nager.at for: {', '.join(sorted(countries))} ...")
|
||||
holidays_by_country = {c: get_public_holidays(c, years) for c in countries}
|
||||
|
||||
print("Resolving Jira account IDs from email addresses...")
|
||||
email_to_id = resolve_account_ids(
|
||||
[e["email"] for e in employees], jira_base_url, jira_email, jira_token
|
||||
)
|
||||
for emp in employees:
|
||||
emp["account_id"] = email_to_id[emp["email"]]
|
||||
|
||||
known_ids = {e["account_id"] for e in employees}
|
||||
print(f"Fetching Tempo worklogs from {args.tempo_base_url}...")
|
||||
all_worklogs = fetch_tempo_worklogs(start, end, tempo_token, args.tempo_base_url)
|
||||
|
||||
# Detect reporters not in employees.md
|
||||
unknown_ids = [aid for aid in all_worklogs if aid not in known_ids]
|
||||
unknown_reporters: list[dict] = []
|
||||
if unknown_ids:
|
||||
print(f"Resolving {len(unknown_ids)} unknown reporter(s) from Jira...")
|
||||
account_info = resolve_account_info(unknown_ids, jira_base_url, jira_email, jira_token)
|
||||
for aid in unknown_ids:
|
||||
info = account_info[aid]
|
||||
total_h = round(sum(all_worklogs[aid].values()), 2)
|
||||
unknown_reporters.append({
|
||||
"account_id": aid,
|
||||
"display_name": info["display_name"],
|
||||
"email": info["email"],
|
||||
"reported_hours": total_h,
|
||||
})
|
||||
print(f" Not in employees.md: {info['display_name']} ({info['email']}) — {total_h:.1f}h")
|
||||
unknown_reporters.sort(key=lambda r: r["reported_hours"], reverse=True)
|
||||
|
||||
stats = compute_stats(employees, start, end, holidays_by_country, all_worklogs)
|
||||
total_expected = sum(r["expected_hours"] for r in stats)
|
||||
total_reported = round(sum(r["reported_hours"] for r in stats), 2)
|
||||
overall_pct = round((total_reported / total_expected * 100) if total_expected > 0 else 100.0, 1)
|
||||
|
||||
# Annotate with email for readability
|
||||
for stat in stats:
|
||||
emp = next(e for e in employees if e["account_id"] == stat["account_id"])
|
||||
stat["email"] = emp["email"]
|
||||
|
||||
working_days_by_country = {
|
||||
c: len(get_working_days(start, end, holidays_by_country[c]))
|
||||
for c in sorted(countries)
|
||||
}
|
||||
|
||||
output = {
|
||||
"period": {
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"label": label,
|
||||
"working_days_by_country": working_days_by_country,
|
||||
},
|
||||
"summary": {
|
||||
"total_employees": len(stats),
|
||||
"total_expected_hours": total_expected,
|
||||
"total_reported_hours": total_reported,
|
||||
"overall_completion_pct": overall_pct,
|
||||
"employees_fully_complete": sum(1 for r in stats if r["completion_pct"] >= 100),
|
||||
"employees_with_gaps": sum(1 for r in stats if r["completion_pct"] < 100),
|
||||
},
|
||||
"employees": stats,
|
||||
"unknown_reporters": unknown_reporters,
|
||||
}
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = output_dir / "timesheet_data.json"
|
||||
out_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
|
||||
print(f"Wrote {out_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch Tempo worklogs and compute timesheet completion."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--period",
|
||||
required=True,
|
||||
help="Period to check: 'last-week', 'last-month', or 'YYYY-MM-DD:YYYY-MM-DD'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--employees",
|
||||
help=(
|
||||
"Path to the employees markdown config file. "
|
||||
"If omitted, looks for employees.md in the current directory, "
|
||||
"then references/employees.md in the skill folder."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
required=True,
|
||||
help="Directory for timesheet_data.json.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tempo-token",
|
||||
help="Tempo API token. Defaults to TEMPO_API_TOKEN env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tempo-base-url",
|
||||
default="https://api.tempo.io/4",
|
||||
help="Tempo API base URL. Default: https://api.tempo.io/4 (Tempo Cloud).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-base-url",
|
||||
help="Jira base URL, e.g. https://company.atlassian.net. Defaults to JIRA_BASE_URL env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-email",
|
||||
help="Email address used to authenticate with Jira. Defaults to JIRA_EMAIL env var.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jira-token",
|
||||
help="Jira personal API token. Defaults to JIRA_API_TOKEN env var.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run(build_parser().parse_args()))
|
||||
302
.agents/skills/transcript-speaker-fill/SKILL.md
Normal file
302
.agents/skills/transcript-speaker-fill/SKILL.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
---
|
||||
name: transcript-speaker-fill
|
||||
description: Recover real speaker names in a Fireflies transcript that only has generic "Speaker N" labels, by fuzzy-matching timestamps against a second transcript of the same meeting that has real names but broken/unusable text (commonly caused by wrong-language detection). Use when the user has two Fireflies exports of one meeting - one with correct text but no speaker names, one with real names but garbled text - and wants the names filled into the good transcript. Trigger phrases: "fill in the speakers", "match speakers by timestamp", "recover speaker names", "one transcript is missing speakers and the other has the wrong language".
|
||||
---
|
||||
|
||||
# Transcript speaker fill skill
|
||||
|
||||
## Purpose
|
||||
|
||||
Fireflies occasionally produces two broken outcomes for the same meeting:
|
||||
one export has correct transcript text but generic `Speaker 1`, `Speaker 2`,
|
||||
... labels (diarization worked, but names were never resolved or the
|
||||
Fireflies bot lost the participant roster); another export - often from a
|
||||
retry after fixing the language setting - has real speaker names but
|
||||
garbage text (wrong language was detected, so the words are nonsense, but
|
||||
the underlying speaker diarization and timestamps are still meaningful).
|
||||
|
||||
This skill cross-references the two: it can't read the garbled text, but it
|
||||
can compare *when* each speaker was talking, and use that timing to guess
|
||||
which generic label corresponds to which real name in the good transcript.
|
||||
|
||||
This is entirely mechanical - a bundled Python script does the parsing,
|
||||
offset detection, and voting. Nothing here needs model judgment except
|
||||
interpreting the final report and deciding whether the result is trustworthy
|
||||
enough to use.
|
||||
|
||||
## Important: this is best-effort, not a solved match
|
||||
|
||||
Be direct with the user about this before and after running it:
|
||||
|
||||
- **Some speakers may be structurally unresolvable.** If the target
|
||||
transcript has more distinct unnamed speakers than the broken transcript
|
||||
has distinct real names, some target speakers simply aren't captured
|
||||
with a real name *anywhere* in the broken file (they may have joined
|
||||
late, or their voice wasn't separated out in that broken run). No amount
|
||||
of tuning fixes this - the script detects and reports this gap explicitly,
|
||||
but can still produce a confident-*looking* wrong answer for an affected
|
||||
label, because it has no way to know a name is entirely absent from the
|
||||
candidate pool.
|
||||
- **Timestamps are start-of-utterance markers from two independent
|
||||
diarization runs**, not a shared clock - they can disagree by several
|
||||
seconds even for a genuine match, and the two files may have a constant
|
||||
offset if the bots didn't start recording at exactly the same instant.
|
||||
The script searches for that offset automatically; it can still get it
|
||||
slightly wrong in a noisy recording.
|
||||
- **Always spot-check the result** - especially any label resolved with
|
||||
fewer than ~4-5 votes or under ~65% confidence, and *especially* any
|
||||
label affected by the roster-gap warning. Read a couple of the actual
|
||||
lines attributed to a resolved name and sanity-check against tone/content
|
||||
(a name attributed to a monologue about hairdressers when the person is
|
||||
known to talk mostly about delivery process, say, is a red flag).
|
||||
|
||||
## How to run this skill
|
||||
|
||||
This is a two-pass flow: a dry-run preview first, a write-to-disk second,
|
||||
only after the matches have been confirmed with the user.
|
||||
|
||||
### Pass 1 - preview (dry run, default)
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fill_speakers.py" "<file-1>" "<file-2>"
|
||||
```
|
||||
|
||||
- `<file-1>` / `<file-2>` - the two transcripts, **in either order**. The
|
||||
script auto-detects which one is the broken source (real speaker names,
|
||||
garbled/wrong-language text) and which is the target (correct text,
|
||||
generic `Speaker N` labels) by comparing how much of each file is still
|
||||
labeled with generic `Speaker N` names - it does **not** rely on file
|
||||
size or on which argument came first. It prints which role it assigned
|
||||
to each file near the top of the output - check that this matches what
|
||||
you'd expect from the filenames/content before trusting the rest of the
|
||||
report. If the two files are too similar to tell apart (rare), it exits
|
||||
with an error instead of guessing - open both and check by eye: the
|
||||
broken one reads as nonsense/wrong language but has real names; the
|
||||
target reads correctly but has `Speaker N` labels.
|
||||
|
||||
Resolve `<skill-dir>` to this skill's own directory.
|
||||
|
||||
With no `--apply` flag, the script **only prints the matching report** -
|
||||
merges, roster-gap warning, offset, the per-label resolution table, and
|
||||
any unresolved labels with text snippets. It does not touch disk yet.
|
||||
Present this report to the user (see "After the preview" below) and get
|
||||
their explicit confirmation - or their corrections via `--manual` - before
|
||||
moving to pass 2.
|
||||
|
||||
### Pass 2 - apply (only after confirmation)
|
||||
|
||||
Once the user has confirmed the matches (and supplied any `--manual`
|
||||
overrides for gaps or corrections), re-run the exact same command with
|
||||
`--apply` added:
|
||||
|
||||
```bash
|
||||
python3 "<skill-dir>/scripts/fill_speakers.py" "<file-1>" "<file-2>" --apply [--manual "Speaker N=Real Name" ...]
|
||||
```
|
||||
|
||||
Only this run writes the output file. It never modifies either input
|
||||
file - it writes a new file next to the target, named
|
||||
`<target-stem>-speakers-filled.md` by default (override with `--output
|
||||
<path>`), with every generic label it could confidently resolve (or that
|
||||
was given via `--manual`) replaced by the real name throughout the
|
||||
target - one resolution per label, applied consistently everywhere that
|
||||
label appears, not line-by-line guessing.
|
||||
|
||||
Do not pass `--apply` on the first run, and do not treat pass 1's report
|
||||
as final - it is a proposal for the user to react to, not a completed
|
||||
action.
|
||||
|
||||
Useful tuning flags if the default report looks too conservative or too
|
||||
noisy:
|
||||
- `--tolerance <seconds>` (default 15) - how close two timestamps must be
|
||||
to count as a candidate match.
|
||||
- `--min-votes <n>` (default 2) / `--min-confidence <0-1>` (default 0.5) -
|
||||
how many matches, and what vote share, a label needs before it gets
|
||||
resolved instead of left as `Speaker N`. Raise both for a more
|
||||
conservative (fewer, more trustworthy) result.
|
||||
- `--offset-search <seconds>` (default 90) - how wide a window to search
|
||||
for a constant clock offset between the two recordings.
|
||||
- `--no-normalize` - by default, the broken file's own duplicate-diarization
|
||||
name variants are merged before voting (see next section) - most
|
||||
commonly a trailing digit Fireflies adds when it's unsure two segments
|
||||
are the same voice cluster (e.g. `Robert Drazkowski` and
|
||||
`Robert Drazkowski1` collapse into one candidate name, `Robert
|
||||
Drazkowski`). Disable this only if that assumption is wrong for a given
|
||||
pair of files - e.g. the trailing digit is genuinely disambiguating two
|
||||
different real people who happen to share a name, which would be
|
||||
unusual but isn't impossible.
|
||||
- `--manual "Speaker N=Real Name"` (repeatable) - force a specific label to
|
||||
a specific name directly, bypassing matching entirely. This is how you
|
||||
apply names the user supplies for labels the automatic pass couldn't
|
||||
resolve (see "After the preview" below) - it overrides any automatic
|
||||
result for that label, confident or not, and works even for a label
|
||||
that had zero automatic matches at all.
|
||||
- **Partial names are matched against the broken file's roster
|
||||
automatically.** If the user only gives a first name (or any partial
|
||||
string) and it matches exactly one real name already appearing in the
|
||||
broken file - e.g. `--manual "Speaker 3=Dawid"` when the roster
|
||||
contains `Dawid Cieślicki` and no one else called Dawid - the script
|
||||
expands it to the full roster name and prints a `NOTE (Speaker 3): ...`
|
||||
line saying so. Relay that note to the user so they know which full
|
||||
name actually got applied. If the partial name matches *more than one*
|
||||
roster name, it's genuinely ambiguous - the script keeps the literal
|
||||
string as given (does not guess) and prints a warning listing every
|
||||
candidate it could have meant; relay that to the user and ask them to
|
||||
supply the full name instead. If it matches *no one* in the roster, the
|
||||
literal name is used as-is with no note - that's the expected, normal
|
||||
case for a real participant who was never captured with a name
|
||||
anywhere in the broken file either (the roster-gap scenario), not an
|
||||
error.
|
||||
- `--examples <n>` (default 5) - how many timestamped example lines to
|
||||
print per label (spread across its timeline) for the user to spot-check
|
||||
against the actual recording. Raise it if a label needs more coverage
|
||||
before the user is comfortable confirming it.
|
||||
- `--recording-url <url>` - a link to the target meeting's recording (e.g.
|
||||
a Fireflies share URL). If given, it's printed once at the top and
|
||||
repeated under every label next to its example lines, so a reviewer has
|
||||
one click to the recording right where they need it. There's no verified
|
||||
way to encode an exact-timestamp deep link for Fireflies (their own UI
|
||||
has a "copy link to this moment" feature, but the query parameter it
|
||||
produces isn't publicly documented), so this links to the recording
|
||||
itself - the user still scrubs to each example's timestamp manually.
|
||||
|
||||
## Same speaker, two labels in the broken file
|
||||
|
||||
Fireflies sometimes emits two different name strings for what is actually
|
||||
one person, when its diarization isn't confident two speech segments are
|
||||
the same voice cluster - most visibly a trailing digit appended to an
|
||||
otherwise-identical name (`Robert Drazkowski` / `Robert Drazkowski1`). Left
|
||||
unhandled, this would split one real person's votes across two candidate
|
||||
names and could prevent either from reaching the resolution threshold, or
|
||||
worse, cause the script to treat them as two different (wrong) people.
|
||||
|
||||
The script merges these automatically before voting (`normalize_name`,
|
||||
default on) and **prints exactly what it merged** near the top of its
|
||||
output - always check that block. If it merged something that was actually
|
||||
two different people, or missed a variant that isn't a bare trailing digit
|
||||
(e.g. `Name (2)` or `Name_2` - the normalizer also handles these, but a
|
||||
truly unusual format might slip through), re-run with `--no-normalize` and
|
||||
handle that pair of names manually via `--manual` instead.
|
||||
|
||||
## After the preview (pass 1)
|
||||
|
||||
Report to the user, based on the script's own printed output, **before**
|
||||
ever running pass 2:
|
||||
- Which file it auto-detected as broken vs target - flag it if this looks
|
||||
wrong given the filenames/content.
|
||||
- Any merged duplicate-diarization labels (so they can sanity-check the
|
||||
merge was correct, not two different people collapsed into one).
|
||||
- The roster-gap warning, if any (how many target speakers can't
|
||||
structurally be resolved).
|
||||
- The offset it settled on.
|
||||
- The per-label resolution table (name/UNRESOLVED, vote count, confidence)
|
||||
- present this as proposed matches for the user to accept, not as a
|
||||
done deal.
|
||||
- **Any other candidates for a label, most to least probable.** Whenever a
|
||||
label had votes for more than one real name, the script prints a second
|
||||
line under it - `other candidates, most to least probable: ...` for a
|
||||
resolved label, `all candidates, most to least probable: ...` for an
|
||||
unresolved one - each with its own vote count and vote share. Always
|
||||
relay this ranked list, not just the winning name, especially when the
|
||||
top two candidates are close in vote share (e.g. 45% vs 40%): that's a
|
||||
near-tie, not a confident resolution, and the user may recognize the
|
||||
second-place name as the right one from the snippets.
|
||||
- **The example lines printed under every label** - the script picks up to
|
||||
`--examples` (default 5) lines per label, spread across that label's full
|
||||
timeline rather than clustered at the start, and prints each with its
|
||||
timestamp *in the target recording's own timeline* (not offset-adjusted -
|
||||
these are the timestamps to scrub to in the actual recording/video, since
|
||||
that's what the user has playback access to). This applies to every
|
||||
label, not just unresolved ones - relay them for the resolved labels too,
|
||||
and explicitly suggest the user jump to a couple of these timestamps in
|
||||
the recording and confirm by ear who's actually speaking, especially for
|
||||
anything under ~70% confidence. This is the concrete way to turn "the
|
||||
vote count says X" into "I checked and it's actually X" - don't skip
|
||||
offering it just because a label came back resolved. Raise `--examples`
|
||||
if the user wants more per label to check. If you have a link to the
|
||||
recording, pass it via `--recording-url` so it's printed alongside every
|
||||
label's examples - don't make the user go find the meeting themselves.
|
||||
- **When a `--manual` override is given, the automatic guess is still shown
|
||||
underneath it, not discarded** - the header line says whether the manual
|
||||
name *agrees with* the automatic guess, *overrides* it (naming what the
|
||||
automatic pass would have picked instead, and at what confidence), or
|
||||
fills a gap the automatic pass left unresolved. Relay this distinction -
|
||||
an override that contradicts a high-confidence automatic guess is worth
|
||||
flagging back to the user as a "you sure?" before applying, whereas one
|
||||
that just fills an unresolved gap or agrees with the automatic guess
|
||||
needs no extra scrutiny.
|
||||
- The minority-vote lines flagged for manual review, if any.
|
||||
- **If the roster-gap warning fired, look for suspicious patterns across
|
||||
multiple labels** before taking individual resolutions at face value -
|
||||
e.g. two different labels both resolving to the same real name, each at
|
||||
middling confidence with the same runner-up(s), is a sign that one of
|
||||
them is actually an unnamed real participant being misattributed, not
|
||||
genuinely two clusters of the same person. Flag this pattern explicitly
|
||||
to the user rather than reporting each label's line as independently
|
||||
fine.
|
||||
|
||||
Then explicitly recommend spot-checking the lowest-confidence resolutions
|
||||
against actual dialogue content - and, now that timestamps are available
|
||||
for every label, against the actual recording audio - before accepting
|
||||
them. Do not present the preview as a finished, verified transcript.
|
||||
|
||||
**Always relay the script's "Could not match" section and actually ask
|
||||
the user about it** - don't just print it and move on. For each
|
||||
unresolved label, show the line count and its example timestamps, then ask
|
||||
something like: "I couldn't match Speaker 2, 4, and 9 - here's what each
|
||||
said, with timestamps to check in the recording [examples]. Do you know
|
||||
who any of these are?"
|
||||
|
||||
**Wait for explicit confirmation before running pass 2 (`--apply`).** The
|
||||
user needs to either:
|
||||
- confirm the proposed matches look right, and/or
|
||||
- supply real names for any unresolved gaps, and/or
|
||||
- correct any match that looks wrong (even a "resolved" one they don't
|
||||
trust).
|
||||
|
||||
Fold whatever they give you into `--manual "Speaker N=Name"` flags (one
|
||||
per label) on the pass-2 run - this overrides the automatic result for
|
||||
that label, confident or not. Then re-run with `--apply` and show the
|
||||
updated report. Don't hand-edit the output file directly, since a re-run
|
||||
keeps the automatic resolutions and the merge/roster reporting consistent
|
||||
with the final file.
|
||||
|
||||
If the user wants a stricter or looser automatic pass instead, re-run
|
||||
pass 1 (still without `--apply`) with adjusted
|
||||
`--tolerance`/`--min-votes`/`--min-confidence` and present the new
|
||||
preview before applying anything.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Neither file matches the expected `**Speaker** *[MM:SS]*: text` format**
|
||||
(e.g. it's an `.mhtml` capture, not an extracted `.md`) - point the user
|
||||
at the `extract-transcript` skill first to get a proper Markdown export.
|
||||
- **A generic label appears in a burst with no broken-file activity nearby
|
||||
at all** (e.g. everyone else was silent while this person spoke for a
|
||||
while) - it will correctly come back `UNRESOLVED (no timestamp within
|
||||
tolerance found at all)` rather than a forced guess.
|
||||
- **The target file already has some real names mixed with `Speaker N`
|
||||
labels** (partial resolution done elsewhere) - only the `Speaker N`
|
||||
entries are touched; already-named lines are left exactly as they are.
|
||||
This doesn't confuse auto-detection either, since it compares the
|
||||
*share* of generic labels between the two files, not just their
|
||||
presence.
|
||||
- **Auto-detection can't tell the files apart** (exactly equal share of
|
||||
generic labels in both, e.g. both 0% or both 100%) - the script exits
|
||||
with an error rather than guessing, since swapping argument order
|
||||
wouldn't change the outcome either. Inspect both files by eye instead -
|
||||
it likely means one file isn't in the format expected, or this isn't
|
||||
actually a broken/target pair.
|
||||
- **Re-running after tuning flags or `--manual` overrides** - always safe
|
||||
in either pass. Without `--apply` nothing is ever written, and with
|
||||
`--apply` each run's output filename defaults to the same path
|
||||
(overwritten on re-run, not accumulated).
|
||||
- **`--manual` references a label that doesn't exist in the target** (typo,
|
||||
or a label that already has a real name) - the script warns and ignores
|
||||
it rather than silently doing nothing; check the warning if a manual
|
||||
override doesn't seem to have taken effect.
|
||||
- **A `--manual` partial name matches more than one roster name** (e.g. two
|
||||
different real people in the broken file share a first name) - the
|
||||
script refuses to guess, uses the literal string as given, and prints a
|
||||
warning listing every candidate it could have meant. Relay this to the
|
||||
user and get the full name before applying, rather than letting the
|
||||
ambiguous literal string silently become the final label.
|
||||
480
.agents/skills/transcript-speaker-fill/scripts/fill_speakers.py
Normal file
480
.agents/skills/transcript-speaker-fill/scripts/fill_speakers.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fill in real speaker names in a Fireflies-style transcript by fuzzy-matching
|
||||
timestamps against a second transcript of the same meeting that has real
|
||||
speaker names but broken/unusable text (e.g. wrong language was detected,
|
||||
so the words are garbage but the diarization + speaker labels are fine).
|
||||
|
||||
Both inputs are expected in Fireflies' Markdown export shape:
|
||||
**Speaker Name** *[MM:SS]*: text
|
||||
|
||||
The two file arguments can be given in either order - whichever one has a
|
||||
higher share of generic "Speaker N" labels is auto-detected as the target
|
||||
to fill in; the other is treated as the broken source of real names.
|
||||
|
||||
By default this is a dry run: it only prints the matching report. Nothing
|
||||
is written until it is re-run with --apply, so the report can be reviewed
|
||||
(and --manual overrides added for any gaps) before anything touches disk.
|
||||
|
||||
Deterministic, no model reasoning involved - see the accompanying SKILL.md
|
||||
for when/how to invoke this.
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import defaultdict, Counter
|
||||
|
||||
LINE_RE = re.compile(
|
||||
r'^\*\*(?P<speaker>[^*]+)\*\*\s*\*\[(?P<ts>\d{1,2}:\d{2}(?::\d{2})?)\]\*:\s*(?P<text>.*)$'
|
||||
)
|
||||
GENERIC_RE = re.compile(r'^Speaker\s+\d+$')
|
||||
|
||||
|
||||
def parse_ts(ts):
|
||||
parts = [int(p) for p in ts.split(':')]
|
||||
if len(parts) == 2:
|
||||
m, s = parts
|
||||
return m * 60 + s
|
||||
h, m, s = parts
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
|
||||
def normalize_name(name):
|
||||
"""Fireflies sometimes labels the same real person with two different
|
||||
strings when it isn't sure two segments are the same voice cluster -
|
||||
most commonly a trailing digit appended to an otherwise-identical name
|
||||
(e.g. "Robert Drazkowski" and "Robert Drazkowski1" are the same person).
|
||||
Strip that suffix so both collapse into one candidate name for voting.
|
||||
Also strips a trailing " 2", "(2)", "_2" etc. in case Fireflies uses one
|
||||
of those variants instead of a bare digit."""
|
||||
cleaned = re.sub(r'[\s_]*\(?\d+\)?$', '', name).strip()
|
||||
return cleaned if cleaned else name.strip()
|
||||
|
||||
|
||||
def parse_manual_overrides(pairs):
|
||||
"""Parse repeated --manual "Speaker N=Real Name" arguments into a dict."""
|
||||
overrides = {}
|
||||
for pair in pairs or []:
|
||||
if '=' not in pair:
|
||||
sys.exit(f"--manual expects 'Speaker N=Real Name', got: {pair!r}")
|
||||
label, name = pair.split('=', 1)
|
||||
overrides[label.strip()] = name.strip()
|
||||
return overrides
|
||||
|
||||
|
||||
def resolve_partial_name(name, roster):
|
||||
"""If `name` is a partial reference (e.g. just a first name) to someone
|
||||
who already appears in `roster` (the broken file's real-name roster),
|
||||
suggest/expand to the matching full name instead of taking the partial
|
||||
string literally. Returns (final_name, note_or_None).
|
||||
|
||||
Matching is deliberately conservative: an exact match short-circuits
|
||||
immediately; otherwise a candidate qualifies only if `name` shares a
|
||||
whole word with it (case-insensitive) or is a substring of it. If more
|
||||
than one roster name qualifies, this is ambiguous - the literal name is
|
||||
kept as given rather than guessing, with a warning listing the
|
||||
candidates so the user can specify which one they meant. If none
|
||||
qualify, the name is genuinely new (e.g. a real participant who was
|
||||
never captured with a name anywhere in the broken file) and is used
|
||||
as-is - that's expected, not an error."""
|
||||
if name in roster:
|
||||
return name, None
|
||||
|
||||
name_lower = name.strip().lower()
|
||||
name_tokens = set(name_lower.split())
|
||||
candidates = []
|
||||
for full in roster:
|
||||
full_lower = full.lower()
|
||||
if name_lower == full_lower:
|
||||
return full, f"'{name}' matches roster name '{full}' (case-insensitive) - using '{full}'."
|
||||
full_tokens = set(full_lower.split())
|
||||
if name_tokens & full_tokens or name_lower in full_lower:
|
||||
candidates.append(full)
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates[0], f"'{name}' looks like a partial name - matched to the only candidate in the roster, '{candidates[0]}'. Using the full name."
|
||||
if len(candidates) > 1:
|
||||
return name, (
|
||||
f"'{name}' is ambiguous - it could refer to any of: {', '.join(candidates)}. "
|
||||
f"Used literally as given since I can't tell which one you meant - re-run with the full "
|
||||
f"name to disambiguate if this isn't who you intended."
|
||||
)
|
||||
return name, None
|
||||
|
||||
|
||||
def parse_file(path):
|
||||
entries = []
|
||||
for i, line in enumerate(Path(path).read_text(encoding='utf-8').splitlines()):
|
||||
m = LINE_RE.match(line.strip())
|
||||
if m:
|
||||
entries.append({
|
||||
'line_no': i,
|
||||
'speaker': m.group('speaker').strip(),
|
||||
'seconds': parse_ts(m.group('ts')),
|
||||
'text': m.group('text'),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
def classify(entries_a, path_a, entries_b, path_b):
|
||||
"""Decide which of the two parsed transcripts is the 'target' (has
|
||||
generic Speaker N labels needing real names filled in) and which is
|
||||
the 'broken' source (has real names already, used only for its
|
||||
timestamps). Whichever file has a higher share of generic-labeled
|
||||
lines is the target - the broken source should have few or none,
|
||||
since its diarization already resolved real names even though its
|
||||
text is garbled. This replaces any assumption about file size or
|
||||
argument order."""
|
||||
def generic_fraction(entries):
|
||||
if not entries:
|
||||
return 0.0
|
||||
generic = sum(1 for e in entries if GENERIC_RE.match(e['speaker']))
|
||||
return generic / len(entries)
|
||||
|
||||
frac_a = generic_fraction(entries_a)
|
||||
frac_b = generic_fraction(entries_b)
|
||||
|
||||
if frac_a == frac_b:
|
||||
sys.exit(
|
||||
f"Could not automatically tell which file needs speaker names filled in: "
|
||||
f"both '{path_a}' and '{path_b}' have the same share of generic 'Speaker N' "
|
||||
f"labels ({frac_a:.0%}). Check the files by eye - the target should read as "
|
||||
f"real dialogue with generic labels, the broken source should have real names "
|
||||
f"but garbled/wrong-language text."
|
||||
)
|
||||
|
||||
if frac_a > frac_b:
|
||||
return (entries_a, path_a), (entries_b, path_b)
|
||||
return (entries_b, path_b), (entries_a, path_a)
|
||||
|
||||
|
||||
def proximity_weight(delta, tolerance):
|
||||
"""1.0 for an exact match, decaying linearly to just above 0 at the
|
||||
tolerance boundary. A match a few seconds off is real signal; a match
|
||||
12 seconds off inside a 15s tolerance is mostly noise - weight
|
||||
accordingly rather than counting both as one equal 'vote'."""
|
||||
return max(0.0, 1.0 - delta / (tolerance + 1))
|
||||
|
||||
|
||||
def find_best_offset(anchor_entries, target_entries, tolerance, offset_range):
|
||||
"""Search for a constant clock offset (seconds) between the two
|
||||
recordings that maximizes total proximity-weighted overlap between
|
||||
target and anchor timestamps. Handles the two Fireflies bots not
|
||||
starting at exactly the same instant. Weighted (not a raw count of
|
||||
"any match within tolerance") so a wide tolerance can't let a wrong
|
||||
offset win just by picking up many loose, low-quality matches."""
|
||||
anchor_times = [e['seconds'] for e in anchor_entries]
|
||||
best_offset, best_score = 0, -1.0
|
||||
for offset in range(-offset_range, offset_range + 1):
|
||||
score = 0.0
|
||||
for t in target_entries:
|
||||
tt = t['seconds'] + offset
|
||||
best_delta = min((abs(tt - at) for at in anchor_times), default=tolerance + 1)
|
||||
score += proximity_weight(best_delta, tolerance)
|
||||
if score > best_score or (score == best_score and abs(offset) < abs(best_offset)):
|
||||
best_score, best_offset = score, offset
|
||||
return best_offset, best_score
|
||||
|
||||
|
||||
def nearest_match(seconds, anchor_entries, tolerance):
|
||||
best, best_delta = None, tolerance + 1
|
||||
for a in anchor_entries:
|
||||
delta = abs(a['seconds'] - seconds)
|
||||
if delta <= tolerance and delta < best_delta:
|
||||
best_delta, best = delta, a
|
||||
return best, best_delta
|
||||
|
||||
|
||||
def sample_examples(entries, n):
|
||||
"""Pick up to n example entries spread across the full span of entries
|
||||
(not just the first n) so a spot-check sees variety across the
|
||||
meeting's timeline rather than one early cluster."""
|
||||
if not entries:
|
||||
return []
|
||||
if len(entries) <= n:
|
||||
return entries
|
||||
if n <= 1:
|
||||
return [entries[0]]
|
||||
idxs = sorted({round(i * (len(entries) - 1) / (n - 1)) for i in range(n)})
|
||||
return [entries[i] for i in idxs]
|
||||
|
||||
|
||||
def format_examples(entries, indent=' '):
|
||||
lines = []
|
||||
for e in entries:
|
||||
mm, ss = divmod(e['seconds'], 60)
|
||||
snippet = e['text'].strip()
|
||||
if len(snippet) > 90:
|
||||
snippet = snippet[:90].rstrip() + "..."
|
||||
lines.append(f"{indent}[{mm:02d}:{ss:02d}] {snippet}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('file_a', help='One of the two transcripts (either order - the broken/target roles are auto-detected)')
|
||||
ap.add_argument('file_b', help='The other transcript')
|
||||
ap.add_argument('--apply', action='store_true',
|
||||
help='Write the output file. Without this flag, only the matching report is '
|
||||
'printed (dry run) so you can review and confirm before anything is written.')
|
||||
ap.add_argument('--tolerance', type=int, default=15,
|
||||
help='Max seconds between matched timestamps (default: 15)')
|
||||
ap.add_argument('--offset-search', type=int, default=90,
|
||||
help='Search +/- this many seconds for a global clock offset between the two recordings (default: 90)')
|
||||
ap.add_argument('--min-votes', type=int, default=2,
|
||||
help='Minimum matched votes required to resolve a label (default: 2)')
|
||||
ap.add_argument('--min-confidence', type=float, default=0.5,
|
||||
help='Minimum vote share (0-1) required to resolve a label (default: 0.5)')
|
||||
ap.add_argument('--output', help='Output path (default: <target-stem>-speakers-filled.md next to target)')
|
||||
ap.add_argument('--no-normalize', action='store_true',
|
||||
help='Do not merge duplicate-diarization name variants (e.g. "Name" / "Name1") in the broken file before voting')
|
||||
ap.add_argument('--manual', action='append', metavar='"Speaker N=Real Name"',
|
||||
help='Force a specific label to a specific name, bypassing matching entirely. '
|
||||
'Repeatable. Overrides any automatic result (confident or not) for that label.')
|
||||
ap.add_argument('--examples', type=int, default=5,
|
||||
help='Example lines (with timestamps) to print per label, spread across its full '
|
||||
'timeline, so matches can be spot-checked against the actual recording (default: 5)')
|
||||
ap.add_argument('--recording-url',
|
||||
help="Link to the target meeting's recording (e.g. a Fireflies share URL). If given, "
|
||||
"it's printed once at the top and repeated under every label so a reviewer can "
|
||||
"jump straight to it and scrub to each example's timestamp. There is no verified "
|
||||
"way to encode an exact-timestamp deep link for Fireflies (the parameter their "
|
||||
"own UI uses for 'copy link to this moment' isn't publicly documented), so this "
|
||||
"links to the recording itself, not a specific moment in it.")
|
||||
args = ap.parse_args()
|
||||
|
||||
manual_overrides = parse_manual_overrides(args.manual)
|
||||
|
||||
entries_a = parse_file(args.file_a)
|
||||
entries_b = parse_file(args.file_b)
|
||||
|
||||
if not entries_a:
|
||||
sys.exit(f"No '**Speaker** *[MM:SS]*: text' lines found in {args.file_a} - check the format.")
|
||||
if not entries_b:
|
||||
sys.exit(f"No '**Speaker** *[MM:SS]*: text' lines found in {args.file_b} - check the format.")
|
||||
|
||||
(target_entries, target_path), (broken_entries, broken_path) = classify(
|
||||
entries_a, args.file_a, entries_b, args.file_b
|
||||
)
|
||||
print(f"Auto-detected roles: '{broken_path}' has real names (broken source), "
|
||||
f"'{target_path}' has generic labels to fill (target).")
|
||||
print()
|
||||
|
||||
merged_variants = defaultdict(set)
|
||||
if not args.no_normalize:
|
||||
for b in broken_entries:
|
||||
raw = b['speaker']
|
||||
normalized = normalize_name(raw)
|
||||
if normalized != raw:
|
||||
merged_variants[normalized].add(raw)
|
||||
b['speaker'] = normalized
|
||||
|
||||
if merged_variants:
|
||||
print("Merged duplicate diarization labels in the broken file (treated as one person):")
|
||||
for normalized, raws in sorted(merged_variants.items()):
|
||||
variants = sorted(raws | {normalized})
|
||||
print(f" {' / '.join(variants)} -> {normalized}")
|
||||
print()
|
||||
|
||||
broken_roster = sorted(set(b['speaker'] for b in broken_entries))
|
||||
target_generic_labels = sorted(set(t['speaker'] for t in target_entries if GENERIC_RE.match(t['speaker'])))
|
||||
if len(target_generic_labels) > len(broken_roster):
|
||||
gap = len(target_generic_labels) - len(broken_roster)
|
||||
print(f"NOTE: the target has {len(target_generic_labels)} distinct unnamed speakers but the broken "
|
||||
f"file only names {len(broken_roster)} real people ({', '.join(broken_roster)}). At least "
|
||||
f"{gap} target speaker(s) are structurally impossible to resolve correctly - they (or their "
|
||||
f"voice segments) simply aren't captured with a real name anywhere in the broken file, so "
|
||||
f"the closest-timestamp match for them, if any, will be a coincidence, not a correspondence. "
|
||||
f"Treat any resolution below with real skepticism, especially ones with few votes.")
|
||||
print()
|
||||
|
||||
offset, offset_score = find_best_offset(broken_entries, target_entries, args.tolerance, args.offset_search)
|
||||
|
||||
weighted_votes = defaultdict(Counter) # label -> name -> summed proximity weight
|
||||
raw_votes = defaultdict(Counter) # label -> name -> raw match count (for the min-votes gate)
|
||||
minority_lines = []
|
||||
for t in target_entries:
|
||||
if not GENERIC_RE.match(t['speaker']):
|
||||
continue
|
||||
match, delta = nearest_match(t['seconds'] + offset, broken_entries, args.tolerance)
|
||||
if match:
|
||||
weighted_votes[t['speaker']][match['speaker']] += proximity_weight(delta, args.tolerance)
|
||||
raw_votes[t['speaker']][match['speaker']] += 1
|
||||
t['_matched_name'] = match['speaker']
|
||||
t['_matched_delta'] = delta
|
||||
|
||||
resolution = {}
|
||||
for label, counter in weighted_votes.items():
|
||||
total_weight = sum(counter.values())
|
||||
ranked = counter.most_common() # all candidates, highest weighted vote share first
|
||||
name, top_weight = ranked[0]
|
||||
top_count = raw_votes[label][name]
|
||||
confidence = top_weight / total_weight if total_weight else 0.0
|
||||
resolved = top_count >= args.min_votes and confidence >= args.min_confidence
|
||||
candidates = [
|
||||
{
|
||||
'name': cand_name,
|
||||
'raw_count': raw_votes[label][cand_name],
|
||||
'share': weight / total_weight if total_weight else 0.0,
|
||||
}
|
||||
for cand_name, weight in ranked
|
||||
]
|
||||
resolution[label] = {
|
||||
'name': name if resolved else None,
|
||||
'top_count': top_count,
|
||||
'total_votes': sum(raw_votes[label].values()),
|
||||
'confidence': confidence,
|
||||
'candidates': candidates,
|
||||
}
|
||||
if resolved:
|
||||
for t in target_entries:
|
||||
if t['speaker'] == label and t.get('_matched_name') and t['_matched_name'] != name:
|
||||
minority_lines.append((t['line_no'], t['seconds'], label, t['_matched_name'], name))
|
||||
|
||||
unknown_manual_labels = [label for label in manual_overrides if label not in target_generic_labels]
|
||||
if unknown_manual_labels:
|
||||
print(f"WARNING: --manual referenced label(s) not found as a generic speaker in the target file: "
|
||||
f"{', '.join(unknown_manual_labels)} - ignoring them. Known generic labels: "
|
||||
f"{', '.join(target_generic_labels)}")
|
||||
print()
|
||||
|
||||
# Resolve any partial names in --manual against the broken file's real-name
|
||||
# roster (e.g. "Dawid" -> "Dawid Cieślicki" if that's the only roster match),
|
||||
# rather than taking the literal string when a better match is available.
|
||||
manual_final = {}
|
||||
for label, raw_name in manual_overrides.items():
|
||||
if label not in target_generic_labels:
|
||||
continue
|
||||
resolved_name, note = resolve_partial_name(raw_name, broken_roster)
|
||||
manual_final[label] = resolved_name
|
||||
if note:
|
||||
print(f"NOTE ({label}): {note}")
|
||||
if manual_final:
|
||||
print()
|
||||
|
||||
# Automatic `resolution` is left untouched here (it stays the source of
|
||||
# truth for the automatic guess/candidates, printed for every label
|
||||
# below regardless of whether a manual override wins); `final_name` is
|
||||
# what actually gets written to disk.
|
||||
final_name = {}
|
||||
for label in target_generic_labels:
|
||||
info = resolution.get(label)
|
||||
final_name[label] = info['name'] if info else None
|
||||
final_name.update(manual_final)
|
||||
|
||||
lines = Path(target_path).read_text(encoding='utf-8').splitlines()
|
||||
resolved_line_count = 0
|
||||
generic_totals = Counter(t['speaker'] for t in target_entries if GENERIC_RE.match(t['speaker']))
|
||||
for t in target_entries:
|
||||
if not GENERIC_RE.match(t['speaker']):
|
||||
continue
|
||||
name = final_name.get(t['speaker'])
|
||||
if name:
|
||||
old = f"**{t['speaker']}**"
|
||||
new = f"**{name}**"
|
||||
lines[t['line_no']] = lines[t['line_no']].replace(old, new, 1)
|
||||
resolved_line_count += 1
|
||||
|
||||
out_path = Path(args.output) if args.output else Path(target_path).with_name(
|
||||
Path(target_path).stem + "-speakers-filled.md"
|
||||
)
|
||||
if args.apply:
|
||||
out_path.write_text("\n".join(lines) + "\n", encoding='utf-8')
|
||||
|
||||
total_generic_lines = sum(generic_totals.values())
|
||||
resolved_labels = sum(1 for name in final_name.values() if name)
|
||||
|
||||
print(f"Global offset applied: {offset:+d}s (best fit: {offset_score}/{len(target_entries)} target lines matched at that offset)")
|
||||
if args.apply:
|
||||
print(f"Output written to: {out_path}")
|
||||
else:
|
||||
print(f"DRY RUN - no file written. Would write to: {out_path}")
|
||||
if args.recording_url:
|
||||
print(f"Recording: {args.recording_url}")
|
||||
print("(no verified way to deep-link an exact timestamp - open this and scrub to each example below)")
|
||||
print()
|
||||
print("Speaker label resolution:")
|
||||
unresolved_labels = []
|
||||
for label in sorted(generic_totals, key=lambda l: -generic_totals[l]):
|
||||
info = resolution.get(label)
|
||||
manual_name = manual_final.get(label)
|
||||
n_lines = generic_totals[label]
|
||||
label_entries = [t for t in target_entries if t['speaker'] == label]
|
||||
examples = sample_examples(label_entries, args.examples)
|
||||
|
||||
# Header line: the FINAL decision for this label (manual wins if given).
|
||||
if manual_name:
|
||||
agreement = ""
|
||||
if info and info.get('name') == manual_name:
|
||||
agreement = " - agrees with automatic guess"
|
||||
elif info and info.get('name'):
|
||||
agreement = f" - OVERRIDES automatic guess of '{info['name']}' ({info['confidence']:.0%} confidence)"
|
||||
elif info:
|
||||
agreement = " - automatic pass left this unresolved"
|
||||
print(f" {label:12s} -> {manual_name:25s} (manually provided{agreement}) - {n_lines} lines")
|
||||
elif info is None:
|
||||
print(f" {label:12s} -> UNRESOLVED (no timestamp within tolerance found at all) - {n_lines} lines")
|
||||
unresolved_labels.append(label)
|
||||
elif info['name']:
|
||||
print(f" {label:12s} -> {info['name']:25s} ({info['top_count']}/{info['total_votes']} votes, "
|
||||
f"{info['confidence']:.0%} confidence) - {n_lines} lines")
|
||||
else:
|
||||
print(f" {label:12s} -> UNRESOLVED (top guess {info['top_count']}/{info['total_votes']} votes, "
|
||||
f"{info['confidence']:.0%} confidence, below threshold) - {n_lines} lines")
|
||||
unresolved_labels.append(label)
|
||||
|
||||
# All candidates with their confidence, for every label regardless of
|
||||
# whether the final answer came from automatic matching or --manual -
|
||||
# so a manual override's plausibility can still be judged against
|
||||
# what the timestamps alone suggested.
|
||||
if info and info.get('candidates'):
|
||||
winner = info.get('name')
|
||||
others = [c for c in info['candidates'] if c['name'] != winner] if winner else info['candidates']
|
||||
label_str = "other candidates" if winner else "all candidates"
|
||||
if others:
|
||||
ranked_str = ", ".join(
|
||||
f"{c['name']} ({c['raw_count']}/{info['total_votes']} votes, {c['share']:.0%})"
|
||||
for c in others
|
||||
)
|
||||
print(f" {label_str}, most to least probable: {ranked_str}")
|
||||
|
||||
if examples:
|
||||
print(f" example lines (jump to these timestamps in the recording to verify):")
|
||||
print(format_examples(examples))
|
||||
if args.recording_url:
|
||||
print(f" recording: {args.recording_url}")
|
||||
print()
|
||||
print(f"Resolved {resolved_labels}/{len(generic_totals)} distinct generic labels, "
|
||||
f"covering {resolved_line_count}/{total_generic_lines} generic-labeled lines.")
|
||||
|
||||
if minority_lines:
|
||||
print()
|
||||
print(f"{len(minority_lines)} individual line(s) disagreed with their label's majority vote "
|
||||
f"(kept the majority name, flagging for manual review):")
|
||||
for line_no, seconds, label, minority_name, majority_name in minority_lines[:20]:
|
||||
mm, ss = divmod(seconds, 60)
|
||||
print(f" line {line_no + 1} [{mm:02d}:{ss:02d}] {label}: nearest match was "
|
||||
f"'{minority_name}', used majority '{majority_name}' instead")
|
||||
if len(minority_lines) > 20:
|
||||
print(f" ... and {len(minority_lines) - 20} more")
|
||||
|
||||
if unresolved_labels:
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(f"Could not match {len(unresolved_labels)} speaker(s): {', '.join(unresolved_labels)} "
|
||||
f"(see their example lines/timestamps above).")
|
||||
print("=" * 70)
|
||||
print("If you know who any of these are, provide their real names and re-run with, e.g.:")
|
||||
example = unresolved_labels[0]
|
||||
print(f' --manual "{example}=Real Name"' + (' --manual "..."' if len(unresolved_labels) > 1 else ''))
|
||||
|
||||
if not args.apply:
|
||||
print()
|
||||
print("-" * 70)
|
||||
print("DRY RUN - nothing was written. Review the resolution table above (and any "
|
||||
"unresolved gaps), add --manual \"Speaker N=Real Name\" for anything to correct "
|
||||
"or fill in, then re-run with --apply to write the output file.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,4 +2,4 @@ libs/*
|
|||
tmp/*
|
||||
outputs/starlight
|
||||
outputs/okf
|
||||
.env
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ retention: high|medium|low # How aggressively to deprioritize when old
|
|||
|
||||
## 3. INGESTION WORKFLOW (TRIGGERED ON DEMAND)
|
||||
|
||||
When the user says "Ingest", "Sync the wiki", or "Update the Wiki" (for syncing this repo's own git history with its remote, see the sync-changes skill under `.claude/skills/` instead):
|
||||
When the user says "Ingest", "Sync the wiki", or "Update the Wiki" (for syncing this repo's own git history with its remote, see the ckb-sync-changes skill under `.claude/skills/` instead):
|
||||
|
||||
1. **Process Inbox:** Scan `raw/inbox/` for new material. After ingesting, move each processed item to `raw/archive/<YYYY-MM-DD>/`, where the date is today's ingestion date (create the dated folder if it doesn't exist yet). If `raw/inbox/` is empty, scan `raw/` directly (excluding `raw/archive/`, which holds already-processed material).
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ v0.1-conformant bundle at `outputs/okf/`, consumable by any generic OKF tool
|
|||
(e.g. Google's reference graph visualizer) without disturbing the richer
|
||||
internal schema (`confidence`/`quality`/`retention`/`supersedes`/dual-linking)
|
||||
that OKF doesn't natively understand. Implemented as a Claude Code Skill —
|
||||
see `.claude/skills/export-okf/SKILL.md` — rather than baked into
|
||||
see `.claude/skills/ckb-export-okf/SKILL.md` — rather than baked into
|
||||
`CLAUDE.md`/`AGENTS.md`, so the mapping ruleset only loads into context when
|
||||
actually invoked.
|
||||
|
||||
|
|
@ -190,7 +190,7 @@ demand: local changes get committed, remote changes get pulled and merged,
|
|||
any conflicts are presented to the user file-by-file to resolve, then the
|
||||
result is pushed automatically. Say "sync changes" to trigger it. Also
|
||||
implemented as a Claude Code Skill — see
|
||||
`.claude/skills/sync-changes/SKILL.md` — and deliberately distinct from the
|
||||
`.claude/skills/ckb-sync-changes/SKILL.md` — and deliberately distinct from the
|
||||
content-level "Sync the wiki" / "Ingest" workflow, which processes
|
||||
`raw/inbox/` into structured `wiki/` pages and has nothing to do with git.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,3 +14,4 @@
|
|||
- User asked to push all changes to the second remote named `codeberg`.
|
||||
- Confirmed local `main` is clean against `origin/main`, fetched `codeberg`, and found divergent histories: local has commits Codeberg lacks, while Codeberg has commits local lacks.
|
||||
- Planned a normal merge of `codeberg/main` into local `main` before pushing, avoiding any force push.
|
||||
- During conflict review, user clarified that the token-shaped value in Codeberg's invoice checker `.env.example` is a test token and should remain.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue