Files
pawplay/ops/report.py
T

237 lines
12 KiB
Python

#!/usr/bin/env python3
"""Create a source-backed PawPlay weekly report without an LLM request."""
import argparse
import calendar
import datetime as dt
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
from zoneinfo import ZoneInfo
TZ = ZoneInfo('Asia/Shanghai')
def read_json(path, default=None):
try:
return json.loads(Path(path).read_text())
except FileNotFoundError:
return default
def save_json(path, value):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False, encoding='utf-8') as f:
json.dump(value, f, ensure_ascii=False, indent=2)
f.write('\n')
staging = f.name
os.replace(staging, path)
def subscription_allocation(budget, start, end):
"""Prorate calendar days; token counts are never a second subscription bill."""
price = budget.get('subscription_usd')
share = budget.get('project_allocation_fraction')
period = budget.get('billing_period')
if price is None or share is None or period not in ('monthly', 'weekly'):
return None
if not isinstance(start, dt.datetime):
start = dt.datetime.combine(start, dt.time(), TZ)
if not isinstance(end, dt.datetime):
end = dt.datetime.combine(end, dt.time(), TZ)
total = 0.0
cursor = start.astimezone(TZ)
end = end.astimezone(TZ)
while cursor < end:
day = cursor.date()
divisor = calendar.monthrange(day.year, day.month)[1] if period == 'monthly' else 7
next_midnight = dt.datetime.combine(day + dt.timedelta(days=1), dt.time(), TZ)
step_end = min(next_midnight, end)
total += float(price) * float(share) / divisor * ((step_end - cursor).total_seconds() / 86400)
cursor = step_end
return round(total, 2)
def session_metadata(path):
with path.open() as f:
for _ in range(4):
line = f.readline()
if not line:
break
try:
value = json.loads(line)
except ValueError:
continue
if value.get('type') == 'session_meta':
return value.get('payload', {})
return {}
def codex_usage(session_root, thread_id, start, end):
"""Use cumulative counter deltas, scoped to the chat and its descendants."""
if not session_root or not thread_id:
return {'available': False, 'reason': '项目会话来源未配置'}
candidates = []
for path in Path(session_root).rglob('*.jsonl'):
meta = session_metadata(path)
candidates.append((path, meta))
allowed = {thread_id}
changed = True
while changed:
changed = False
for _, meta in candidates:
source = meta.get('source', {})
spawn = source.get('subagent', {}).get('thread_spawn', {}) if isinstance(source, dict) else {}
if spawn.get('parent_thread_id') in allowed and meta.get('id') not in allowed:
allowed.add(meta['id'])
changed = True
totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens')}
sessions = 0
for path, meta in candidates:
if meta.get('id') not in allowed:
continue
sessions += 1
previous = dict.fromkeys(totals, 0)
with path.open() as handle:
for line in handle:
try:
event = json.loads(line)
payload = event.get('payload', {})
if event.get('type') != 'event_msg' or payload.get('type') != 'token_count':
continue
current = (payload.get('info') or {}).get('total_token_usage')
if not current:
continue
stamp = dt.datetime.fromisoformat(event['timestamp'].replace('Z', '+00:00'))
if start <= stamp < end:
for key in totals:
totals[key] += max(0, int(current.get(key, 0)) - previous[key])
previous = {k: int(current.get(k, 0)) for k in totals}
except (ValueError, KeyError, TypeError):
continue
return {'available': sessions > 0, 'session_count': sessions, **totals,
'uncached_input_tokens': max(0, totals['input_tokens'] - totals['cached_input_tokens']),
'measurement_note': '会话日志计数含重复处理的上下文和缓存;不是唯一文字数、周额度百分比或API账单。推理token包含于输出计数,不重复相加。'}
def build_report(data_dir, snapshot=False, now=None):
now = now or dt.datetime.now(TZ)
now = now.astimezone(TZ)
cutoff = now.replace(hour=20, minute=0, second=0, microsecond=0) - dt.timedelta(days=(now.weekday() - 4) % 7)
if cutoff > now:
cutoff -= dt.timedelta(days=7)
begin_stamp = cutoff if snapshot else cutoff - dt.timedelta(days=7)
end_stamp = now if snapshot else cutoff
start, end = begin_stamp.date(), end_stamp.date()
config = read_json(data_dir / 'config.json', {})
collector = read_json(data_dir / 'collector.json', {})
ledger = read_json(data_dir / 'ledger.json', {'entries': []})
content = read_json(data_dir / 'content.json', {'items': []})
tasks = read_json(data_dir / 'tasks.json', {'items': []})
findings = read_json(data_dir / 'findings.json', {})
api = read_json(data_dir / 'deepseek-usage.json', {})
entries = []
for entry in ledger.get('entries', []):
if entry.get('project') not in config.get('projects', ['pawplay']):
continue
stamp = entry.get('date', '')
try:
value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00'))
if value.tzinfo is None:
value = value.replace(tzinfo=TZ)
if begin_stamp <= value < end_stamp:
entries.append(entry)
except ValueError:
pass
published = [i for i in content.get('items', []) if i.get('status') == 'published' and i.get('bvid')]
costs = [e for e in entries if e.get('direction') == 'expense' and e.get('currency') == 'USD' and e.get('basis') == 'cash' and e.get('amount') is not None]
incomes = [e for e in entries if e.get('direction') == 'income' and e.get('currency') == 'CNY' and e.get('basis') == 'settled' and e.get('amount') is not None]
allocated = subscription_allocation(config.get('budget', {}), begin_stamp, end_stamp)
usage = codex_usage(collector.get('codex_sessions_dir'), collector.get('thread_id'), begin_stamp, end_stamp)
def observed_this_period(stamp):
try:
value = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00'))
if value.tzinfo is None:
value = value.replace(tzinfo=TZ)
return begin_stamp <= value < end_stamp
except (AttributeError, ValueError):
return False
highlights = []
for item in findings.get('highlights', []):
stamp = item.get('date') if isinstance(item, dict) else findings.get('checked_at')
if observed_this_period(stamp):
highlights.append(item.get('text', '') if isinstance(item, dict) else item)
risks = list(findings.get('risks', []))
if not observed_this_period(findings.get('checked_at')):
risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。')
current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), None)
identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '')
report = {
'id': identifier,
'title': '账号运营' + ('阶段快照' if snapshot else '周报') + ' ' + end.isoformat(),
'period_start': begin_stamp.isoformat(), 'period_end': end_stamp.isoformat(),
'published_at': now.isoformat(), 'partial_period': snapshot, 'kind': 'snapshot' if snapshot else 'weekly',
'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。',
'highlights': highlights,
'risks': risks,
'next_actions': [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8],
'decisions': findings.get('decisions', []),
'metrics': {
'published_project_videos': len(published),
'project_video_views': 0 if not published else None,
'project_game_sessions': None,
'codex': usage,
'deepseek': api or {'status': '未采集'},
'measurement_notes': ['只统计运营账本登记稿件,不计入账户其他历史内容。', '当前会话包含PawPlay、运营基础设施和虚拟人设研究,token仅能按该共享会话汇总,未精确归因到单个方向。', '游戏离线运行且未接入统计,不能推导活跃、留存或转化率。', '已发布稿件存在时,播放数据需接入稿件快照采集,当前不填造数值。']
},
'costs': {
'cash_usd': round(sum(e['amount'] for e in costs), 2) if config.get('cash_tracking_complete') else None,
'confirmed_recorded_cash_usd': round(sum(e['amount'] for e in costs), 2),
'allocated_usd': allocated, 'estimated_usd': None,
'electricity_kwh': None, 'electricity_cny': None,
'notes': ['固定套餐按计费周期的日历天数分摊;项目占比待确认时不报项目分摊金额。', '不按token再次给套餐内使用计费。DeepSeek/API如另计费,需用用量及实际费率/账单入账。', '电费需功率积分和电价,NAS/VPS及硬件折旧需确认项目分摊口径;不填虚构零成本。']
},
'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None,
'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']},
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
}
repo = collector.get('repository')
if repo:
r = subprocess.run(['git', '-C', repo, 'rev-parse', '--short', 'HEAD'], capture_output=True, text=True)
report['sources']['repository_commit'] = r.stdout.strip() if r.returncode == 0 else None
target = data_dir / 'reports' / (identifier + '.json')
# A finalized weekly report is immutable. Manual corrections require a new revision.
if target.exists():
return target, False
save_json(target, report)
return target, True
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--data-dir', required=True, type=Path)
parser.add_argument('--snapshot', action='store_true')
parser.add_argument('--notify', action='store_true')
args = parser.parse_args()
target, created = build_report(args.data_dir, args.snapshot)
print(json.dumps({'created': created, 'report': target.name}, ensure_ascii=False))
collector = read_json(args.data_dir / 'collector.json', {})
if created and collector.get('backup_dir'):
backup = Path(collector['backup_dir']) / target.stem
backup.mkdir(parents=True, exist_ok=True, mode=0o700)
for name in ('config.json', 'ledger.json', 'content.json', 'tasks.json', 'findings.json', 'deepseek-usage.json'):
if (args.data_dir / name).exists():
shutil.copy2(args.data_dir / name, backup / name)
shutil.copy2(target, backup / target.name)
if created and args.notify:
script = collector.get('bark_script')
if script:
subprocess.run(['python3', script, 'PawPlay 周报已归档,可登录运营网站查看预算、收益及下周工作。', '--title', 'PawPlay:每周运营报告', '--url', collector.get('portal_url', '')], check=True)
if __name__ == '__main__':
main()