Add private operations portal and weekly accounting reports
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user