Files
doc/scripts/worklog/transcript.py
T
jiantw83andClaude Sonnet 5 ed7f29c10f refactor(doc): 接上 shared 共用規範,去除重抄段落並修正時區/機密遮蔽引用
依 todo.md 執行的規範治理專案:worklog/funcs/issues-analyze/
issues-analyze-to-file/issues-sync/notifications/docker 七個 skill 改為
引用 shared 新增的 14 個共用 spec(token 優先序、issue 讀取、TODO list、
ask-user、subagent、no-scratch-files、skill-invocation、script-path 等),
不再重抄內容;wiki_api.py 改用 zoneinfo 而非硬編 +8 offset,並補上與
shared/scripts/lib/redact-patterns.json 的對應註記;worklog 的 --tune 改為
呼叫 /jsc-shared:models --task summary。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 06:03:35 +00:00

318 lines
11 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# ==============================================================================
# 用途:worklog 的 transcript 處理工具。負責 (1) 從 Claude Code/Codex
# JSONL 抽出「本輪」對話片段(最後一筆使用者訊息之後的全部內容),
# (2) 估算本輪花費時間,(3) 對文字做機密遮蔽(token/密碼/PII),
# 作為寫入 wiki 前的第二道防線。本檔 REDACT_PATTERNS 現為
# /jsc-shared:spec-gitea『機密遮蔽實作』章節的來源依據(其他工具的
# 機密遮蔽規則以該章節為準)。
# 更新時間:2026/07/27 22:16:00
# 相依:Python 3 標準庫。全程僅走 stdin/stdout,不寫任何檔案。
# ==============================================================================
import json
import re
import sys
from datetime import datetime, timezone
# 單則工具結果/參數的擷取上限,避免整份 transcript 塞進摘要輸入
TOOL_RESULT_LIMIT = 200
TOOL_INPUT_LIMIT = 160
TOTAL_LIMIT = 24000
# ------------------------------------------------------------------------------
# 機密遮蔽規則:命中一律換成 ***
# ------------------------------------------------------------------------------
# 本檔遮蔽規則對應 shared/scripts/lib/redact-patterns.json(經 /jsc-shared:spec-gitea 收斂)
REDACT_PATTERNS = [
(r"[A-Za-z0-9_\-]*:[A-Za-z0-9_\-]{16,}@", "***@"), # URL 內嵌憑證 user:token@
(r"\b[0-9a-f]{40}\b", "***"), # Gitea 40 字元 token
(r"\bgh[pousr]_[A-Za-z0-9_]{16,}\b", "***"), # GitHub token
(r"\bsk-[A-Za-z0-9\-_]{16,}\b", "***"), # API key
(r"(?i)\b(token|password|passwd|pwd|secret|api[_-]?key)\b\s*[:=]\s*\S+", r"\1=***"),
(r"(?i)Authorization:\s*(token|bearer)\s+\S+", r"Authorization: \1 ***"),
(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}", "***"), # Email
(r"\b09\d{2}[-\s]?\d{3}[-\s]?\d{3}\b", "***"), # 台灣手機
(r"\b[A-Z][12]\d{8}\b", "***"), # 身分證字號
]
def redact(text):
"""對文字套用全部機密遮蔽規則,回傳遮蔽後的結果。"""
for pattern, replacement in REDACT_PATTERNS:
text = re.sub(pattern, replacement, text)
return text
def _is_real_user_message(entry):
"""判斷 transcript 條目是否為真正的使用者輸入(排除工具回填與環境注入)。"""
payload = entry.get("payload")
if isinstance(payload, dict) and entry.get("type") == "event_msg":
return payload.get("type") == "user_message" and bool(str(payload.get("message") or "").strip())
if entry.get("type") != "user":
return False
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return any(b.get("type") == "text" for b in content if isinstance(b, dict))
return False
def _blocks(entry):
"""取出條目的 content blocks,統一為 list 形式。"""
content = entry.get("message", {}).get("content")
if isinstance(content, str):
return [{"type": "text", "text": content}]
return content if isinstance(content, list) else []
def _payload_text_blocks(content):
"""把 Codex response_item 的 content blocks 轉成純文字片段。"""
if isinstance(content, str):
return [content]
if not isinstance(content, list):
return []
texts = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") in ("input_text", "output_text", "text"):
text = (block.get("text") or "").strip()
if text:
texts.append(text)
return texts
def _render_codex_payload(entry):
"""將 Codex session JSONL 的 payload 格式轉為摘要輸入用純文字。"""
payload = entry.get("payload")
if not isinstance(payload, dict):
return []
lines = []
entry_type = entry.get("type")
payload_type = payload.get("type")
if entry_type == "event_msg":
if payload_type == "user_message":
message = (payload.get("message") or "").strip()
if message:
lines.append(f"[user] {message}")
elif payload_type == "agent_message":
message = (payload.get("message") or "").strip()
if message:
phase = payload.get("phase") or "assistant"
lines.append(f"[assistant:{phase}] {message}")
return lines
if entry_type != "response_item":
return lines
if payload_type == "message":
role = payload.get("role") or "assistant"
if role in ("system", "developer"):
return lines
for text in _payload_text_blocks(payload.get("content")):
# Codex 會把 skill 內容以 user role 注入;避免把整份 SKILL.md 當成本輪工作。
if role == "user" and text.lstrip().startswith("<skill>"):
continue
if role == "user" and text.lstrip().startswith("<environment_context>"):
continue
lines.append(f"[{role}] {text}")
elif payload_type == "function_call":
name = payload.get("name") or "?"
raw = str(payload.get("arguments") or "").strip().replace("\n", " ")
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
elif payload_type == "function_call_output":
raw = str(payload.get("output") or "").strip().replace("\n", " ")
if raw:
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
return lines
def _render(entry):
"""將單一 transcript 條目轉為摘要輸入用的純文字行(工具結果僅取前段)。"""
codex_lines = _render_codex_payload(entry)
if codex_lines:
return codex_lines
role = entry.get("type")
lines = []
for block in _blocks(entry):
if not isinstance(block, dict):
continue
kind = block.get("type")
if kind == "text":
text = (block.get("text") or "").strip()
if text:
lines.append(f"[{role}] {text}")
elif kind == "tool_use":
name = block.get("name", "?")
raw = json.dumps(block.get("input", {}), ensure_ascii=False)
lines.append(f"[tool:{name}] {raw[:TOOL_INPUT_LIMIT]}")
elif kind == "tool_result":
raw = block.get("content")
if isinstance(raw, list):
raw = " ".join(
b.get("text", "") for b in raw if isinstance(b, dict) and b.get("type") == "text"
)
raw = str(raw or "").strip().replace("\n", " ")
if raw:
lines.append(f"[result] {raw[:TOOL_RESULT_LIMIT]}")
return lines
def _read_entries(path):
"""讀取 transcript JSONL,忽略無法解析的列。"""
try:
with open(path, encoding="utf-8") as fh:
entries = []
for line in fh:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except ValueError:
continue
except OSError:
return []
return entries
def _turn_start_index(entries):
"""找出本輪起點:最後一筆真正使用者訊息的位置。"""
start = 0
for index in range(len(entries) - 1, -1, -1):
if _is_real_user_message(entries[index]):
start = index
break
return start
def _parse_timestamp(value):
"""解析常見 transcript timestamp 格式,失敗回 None。"""
if not isinstance(value, str) or not value.strip():
return None
raw = value.strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
def _entry_timestamp(entry):
"""取出 transcript 條目的時間欄位。"""
for key in ("timestamp", "created_at", "time"):
dt = _parse_timestamp(entry.get(key))
if dt:
return dt
message = entry.get("message")
if isinstance(message, dict):
for key in ("timestamp", "created_at", "time"):
dt = _parse_timestamp(message.get(key))
if dt:
return dt
return None
def format_duration(seconds):
"""把秒數格式化為精簡中文耗時。"""
if seconds < 0:
return "未判定"
minutes = int(round(seconds / 60))
if minutes <= 0:
return "1 分鐘內"
hours, mins = divmod(minutes, 60)
if hours and mins:
return f"{hours} 小時 {mins} 分鐘"
if hours:
return f"{hours} 小時"
return f"{mins} 分鐘"
def turn_duration(path):
"""
估算本輪花費時間:取本輪起點到最後一筆可解析 timestamp 的差距。
transcript 無時間欄位或本輪少於兩個時間點時回「未判定」,避免臆測。
"""
entries = _read_entries(path)
if not entries:
return "未判定"
start = _turn_start_index(entries)
stamps = [dt for dt in (_entry_timestamp(e) for e in entries[start:]) if dt]
if len(stamps) < 2:
return "未判定"
return format_duration((max(stamps) - min(stamps)).total_seconds())
def extract_turn(path):
"""
從 transcript JSONL 抽出本輪內容:最後一筆真正使用者訊息(含該筆)之後的全部條目。
不需任何狀態檔即可界定「本輪」,符合工作內容不落地的要求。
回傳純文字字串;讀取失敗或無內容時回空字串。
"""
entries = _read_entries(path)
if not entries:
return ""
start = _turn_start_index(entries)
lines = []
for entry in entries[start:]:
lines.extend(_render(entry))
text = "\n".join(lines).strip()
if len(text) > TOTAL_LIMIT:
head = text[: TOTAL_LIMIT // 2]
tail = text[-TOTAL_LIMIT // 2 :]
text = f"{head}\n…(中段省略)…\n{tail}"
return text
USAGE = """用法:transcript.py <子命令> [參數]
extract <transcript 路徑> 抽出本輪內容並遮蔽機密後輸出到 stdout
duration <transcript 路徑> 估算本輪花費時間,無法判定時輸出「未判定」
redact 自 stdin 讀取文字,遮蔽機密後輸出到 stdout
"""
def main(argv):
"""CLI 進入點:解析子命令並執行抽取或遮蔽。"""
if not argv or argv[0] in ("-h", "--help"):
print(USAGE)
return 0
if argv[0] == "extract":
if len(argv) < 2:
return 2
text = extract_turn(argv[1])
if not text:
return 1
sys.stdout.write(redact(text))
return 0
if argv[0] == "duration":
if len(argv) < 2:
return 2
sys.stdout.write(turn_duration(argv[1]))
return 0
if argv[0] == "redact":
sys.stdout.write(redact(sys.stdin.read()))
return 0
print(USAGE)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))