Compare commits
13 Commits
20aea13054
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7cae0bee | |||
| f882c9a831 | |||
| 9e5f16dfb8 | |||
| ed978ac0ae | |||
| a5b0b5c9e8 | |||
| 204387efa2 | |||
| ac440f5299 | |||
| dde9742f9c | |||
| 7fc9d4d843 | |||
| 713c3cd0a2 | |||
| 830d5b722a | |||
| ad86a1661b | |||
| 84fc19c965 |
@@ -44,6 +44,8 @@
|
|||||||
|
|
||||||
`billing_period` 支持 `monthly`、`weekly`、`pending`;项目分摊比例范围由生成任务校验,未确认时使用 `null`。
|
`billing_period` 支持 `monthly`、`weekly`、`pending`;项目分摊比例范围由生成任务校验,未确认时使用 `null`。
|
||||||
|
|
||||||
|
`evolution.json` 保存演变路径,登录后在「演变路径」中展开浏览。缺失文件显示空状态;事件按文件中的顺序展示,建议按发生顺序维护。每条事件包含 `id`、`recorded_at`、可选 `occurred_at`、`occurred_at_precision`、`retrospective`、`trigger`、`hypothesis`、`change`、`evidence`、`cost`(`known` 和 `unknown`)、`decision`、`next_step` 与 `artifact_refs`。证据和关联资料可使用可读字符串数组,未知信息应保留而非猜测。写入时应使用临时文件原子替换。
|
||||||
|
|
||||||
`ledger.json`:
|
`ledger.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -110,3 +112,13 @@ npm test
|
|||||||
```
|
```
|
||||||
|
|
||||||
覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
|
覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
|
||||||
|
|
||||||
|
## 每日计划与复盘
|
||||||
|
|
||||||
|
登录后选择「每日计划与复盘」,只读查看 `DATA_DIR/daily/YYYY-MM-DD.json`,日期倒序。每个文件为 JSON 对象,`date` 须与文件名一致,`planned`、`executed`、`evidence`、`costs`、`review`、`tomorrow` 为字符串数组,可省略表示暂无记录;金额未知应明确写待核实,不能默认为零。示例是结构说明,不代表实际工作:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"date":"2026-11-03","planned":["示例计划"],"executed":[],"evidence":[],"costs":["待核实"],"review":[],"tomorrow":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
仅日期文件名参与读取;无效日期、JSON、字段类型或越过数据目录的符号链接将跳过并在页面显示数量。渲染使用文本节点,网页不提供写入或命令执行。每日 Markdown 记录不会自动变成 JSON,须由运营记录流程生成对应 JSON;更新需通过原有挂载数据目录,部署需单独执行。
|
||||||
|
|||||||
+117
-3
@@ -6,7 +6,8 @@ const el = (tag, text, className) => {
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
let dashboard;
|
let dashboard;
|
||||||
const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数' };
|
let currentReportId;
|
||||||
|
const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数', scheduled_codex: '定时运营 Codex 用量', harness_new_runs: '后续 DeepSeek Harness 用量', codex: '项目会话 Codex 用量', deepseek: 'DeepSeek 历史用量快照' };
|
||||||
const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' };
|
const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' };
|
||||||
const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' };
|
const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' };
|
||||||
function valueText(value) {
|
function valueText(value) {
|
||||||
@@ -18,6 +19,8 @@ function valueText(value) {
|
|||||||
}
|
}
|
||||||
function money(value, currency = 'USD') {
|
function money(value, currency = 'USD') {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实';
|
if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实';
|
||||||
|
if (currency === 'BILIBILI_COIN') return `${value} B站硬币`;
|
||||||
|
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) return `${value} ${String(currency)}`;
|
||||||
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
|
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
|
||||||
}
|
}
|
||||||
function dateTime(value) {
|
function dateTime(value) {
|
||||||
@@ -63,6 +66,7 @@ function budgetPanel() {
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
function renderReport(report) {
|
function renderReport(report) {
|
||||||
|
currentReportId = report?.id;
|
||||||
const content = $('#report-content'); content.replaceChildren();
|
const content = $('#report-content'); content.replaceChildren();
|
||||||
const download = $('#download-report');
|
const download = $('#download-report');
|
||||||
if (!report) {
|
if (!report) {
|
||||||
@@ -103,7 +107,105 @@ function renderLedger() {
|
|||||||
}
|
}
|
||||||
table.append(head, body); wrapper.append(table); host.append(wrapper);
|
table.append(head, body); wrapper.append(table); host.append(wrapper);
|
||||||
}
|
}
|
||||||
|
function showView(view) {
|
||||||
|
for (const name of ['overview', 'execution', 'report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
|
||||||
|
for (const [selector, active] of [['#show-overview', view === 'overview'], ['#show-execution', view === 'execution'], ['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) {
|
||||||
|
$(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function openOverview() {
|
||||||
|
showView('overview'); history.replaceState(null, '', '/');
|
||||||
|
const host = $('#overview-content'); host.replaceChildren(el('p', '正在读取运营全景…', 'muted'));
|
||||||
|
try {
|
||||||
|
const [data, queue] = await Promise.all([api('/api/overview'), api('/api/workqueue')]); host.replaceChildren(); const strategy = data.strategy || {}, market = data.market || {};
|
||||||
|
const hero = el('section', undefined, 'report-section'); hero.append(el('h2', '方向与当前阶段'), el('p', strategy.mission || '方向待补充'), el('p', strategy.phase || '', 'muted')); const pillars = el('ul'); for (const item of strategy.pillars || []) pillars.append(el('li', item)); hero.append(pillars, el('p', strategy.cadence || '', 'muted')); host.append(hero);
|
||||||
|
const works = el('section', undefined, 'report-section'); works.append(el('h2', '已发布作品'), el('p', `采样时间:${market.observed_at ? dateTime(market.observed_at) : '暂无'};播放量不是曝光或完播率。`, 'muted'));
|
||||||
|
for (const v of market.account_videos || []) { const p = el('p'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; p.append(a); } else p.append(el('span', v.title)); p.append(document.createTextNode(` · 播放 ${v.views ?? '未知'} · 点赞 ${v.likes ?? '未知'} · 评论 ${v.comments ?? '未知'}`)); works.append(p); } host.append(works);
|
||||||
|
const budget = el('section', undefined, 'report-section'); budget.append(el('h2', '计划投入'), el('p', (strategy.budget || []).map(x => `${x.name} ${x.percent}%`).join(' · ')), el('p', strategy.budget_note || '', 'muted')); host.append(budget);
|
||||||
|
const work = el('section', undefined, 'report-section'); work.append(el('h2', '持续工作')); const cats = {content:'内容',engineering:'工程',research:'研究',community:'社区',management:'管理'}, status = {pending:'待执行',running:'进行中',waiting:'等待触发条件',done:'已完成',blocked:'需要处理'}, list = el('ul'); for (const t of queue.tasks || []) if (t.status !== 'done') list.append(el('li', `${cats[t.category] || '管理'} · ${t.title} · ${status[t.status] || t.status}`)); work.append(list); host.append(work);
|
||||||
|
const trends = el('section', undefined, 'report-section'); trends.append(el('h2', 'B 站公开榜单快照'), el('p', '单时点观察,基于标题和公开数据,不能代表趋势或保证效果。', 'muted')); const ul = el('ul'); for (const v of market.ranking || []) { const li = el('li'); if (v.bvid) { const a = el('a', v.title); a.href = `https://www.bilibili.com/video/${v.bvid}/`; a.target = '_blank'; a.rel = 'noopener noreferrer'; li.append(a); } else li.append(el('span', v.title)); li.append(document.createTextNode(` · ${v.owner} · ${v.views ?? '未知'} 播放`)); ul.append(li); } trends.append(ul); host.append(trends);
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
async function openExecution(updateHistory = true) {
|
||||||
|
showView('execution'); if (updateHistory) history.replaceState(null, '', '/?view=execution');
|
||||||
|
const host = $('#execution-content'); host.replaceChildren(el('p', '正在读取运行状态…', 'muted'));
|
||||||
|
try {
|
||||||
|
const [data, queue] = await Promise.all([api('/api/execution'), api('/api/workqueue')]); host.replaceChildren();
|
||||||
|
const sampled = Date.parse(data.observed_at || ''); const stale = !Number.isFinite(sampled) || Date.now() - sampled > 3 * 60 * 1000;
|
||||||
|
host.append(el('p', `状态采样:${data.observed_at ? dateTime(data.observed_at) : '暂无'}${stale ? ' · 状态可能过期,请检查采集服务' : ' · 约每两分钟刷新'}`, stale ? 'error' : 'muted'));
|
||||||
|
const now = el('section', undefined, 'report-section'); now.append(el('h2', '当前执行'));
|
||||||
|
const cycle = (data.units || []).find(x => x.id === 'cycle'); const active = cycle && ['active','activating'].includes(cycle.state);
|
||||||
|
if (data.daily_budget) now.append(el('p', data.daily_budget.limit == null ? `今日主运营尝试:${data.daily_budget.attempts} 次 · 暂无每日固定次数上限,持续记录用量。` : `今日主运营尝试:${data.daily_budget.attempts} / ${data.daily_budget.limit}。`, 'muted'));
|
||||||
|
now.append(el('p', stale ? '快照已过期,不能确定当前运行情况。' : active ? '主运营进程正在运行。' : cycle?.state === 'inactive' ? '主运营当前空闲,等待可执行任务或预算恢复。' : `主运营状态:${cycle?.state || '未知'}`));
|
||||||
|
for (const t of data.running_tasks || []) now.append(el('p', `领取任务:${t.title} · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); host.append(now);
|
||||||
|
if (data.daily_usage) {
|
||||||
|
const usage = el('section', undefined, 'report-section'); usage.append(el('h2', '今日运行用量'), el('p', '仅统计已结束的定时运营;输入包含缓存输入。套餐不是按 token 逐笔收费,实际额外账单另行核对。', 'muted'));
|
||||||
|
for (const [key, label] of [['subscription', '订阅'], ['api', 'API 备用']]) { const v = data.daily_usage[key] || {}; usage.append(el('p', `${label} · ${v.runs ?? 0} 轮 · 输入 ${v.input_tokens ?? 0}(其中缓存 ${v.cached_input_tokens ?? 0})· 输出 ${v.output_tokens ?? 0} token`)); } host.append(usage);
|
||||||
|
}
|
||||||
|
const schedules = el('section', undefined, 'report-section'); schedules.append(el('h2', '服务与计划'), el('p', '有待执行任务且运行条件满足时,守护程序约每两分钟尝试启动;下方为额外的固定时刻。', 'muted')); const list = el('ul');
|
||||||
|
for (const u of data.units || []) list.append(el('li', `${u.label} · ${u.state === 'activating' || u.state === 'active' ? '正在运行' : u.state === 'inactive' ? '当前空闲' : u.state || '未知'} · 定时器${u.timer_state === 'active' ? '已启用' : u.timer_state || '未知'} · 下次固定时刻 ${u.next_run || '未知'} · 上次结果 ${u.result || '暂无'}`)); schedules.append(list); host.append(schedules);
|
||||||
|
const tasks = el('section', undefined, 'report-section'); tasks.append(el('h2', '任务列表')); const status = {pending:'待执行',running:'已领取',waiting:'等待触发条件',done:'已完成',blocked:'已阻塞'}; const ul = el('ul'); for (const t of queue.tasks || []) ul.append(el('li', `${t.title} · ${status[t.status] || t.status} · 尝试 ${t.attempts} 次 · ${t.updated_at ? dateTime(t.updated_at) : '时间未知'}`)); tasks.append(ul); host.append(tasks);
|
||||||
|
const past = el('section', undefined, 'report-section'); past.append(el('h2', '主运营执行历史'), el('p', '记录来自每轮执行汇总;进程成功不代表整个任务已经完成。详细产出和复盘见每日计划与复盘。', 'muted'));
|
||||||
|
const results = {complete:'进程成功',failed:'失败',timeout:'超时',incomplete:'未记录进度',preflight_failed:'前置检查失败',unknown:'状态未知'}; const rows = el('ul'); for (const x of data.history || []) rows.append(el('li', `${dateTime(x.started_at)} · ${x.title} · ${results[x.status] || '未知'}${x.elapsed_seconds === null ? '' : ` · ${x.elapsed_seconds} 秒`}`)); if (!(data.history || []).length) rows.append(el('li', '暂无执行汇总')); past.append(rows); host.append(past);
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
async function openDaily() {
|
||||||
|
showView('daily');
|
||||||
|
const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted'));
|
||||||
|
history.replaceState(null, '', '/?view=daily');
|
||||||
|
try {
|
||||||
|
const data = await api('/api/daily');
|
||||||
|
host.replaceChildren();
|
||||||
|
try {
|
||||||
|
const queue = await api('/api/workqueue');
|
||||||
|
const work = el('section', undefined, 'report-section');
|
||||||
|
work.append(el('h2', '持续工作'));
|
||||||
|
const status = { pending: '待执行', running: '进行中', waiting: '等待触发条件', done: '已完成', blocked: '需要处理' };
|
||||||
|
const tasks = Array.isArray(queue.tasks) ? queue.tasks : [];
|
||||||
|
if (!tasks.length) work.append(el('p', '暂无任务。', 'muted'));
|
||||||
|
else { const list = el('ul'); for (const task of tasks) list.append(el('li', `${task.title} · ${status[task.status] || task.status} · ${task.updated_at ? dateTime(task.updated_at) : '时间待确认'}`)); work.append(list); }
|
||||||
|
host.append(work);
|
||||||
|
} catch { host.append(el('p', '暂时无法读取持续工作状态。', 'error')); }
|
||||||
|
if (data.invalid_count) host.append(el('p', `${data.invalid_count} 条记录无法读取,请检查数据文件。`, 'error'));
|
||||||
|
if (!data.records.length) host.append(el('p', '暂无每日记录。', 'empty'));
|
||||||
|
for (const record of data.records) {
|
||||||
|
const day = el('details', undefined, 'report-section');
|
||||||
|
const heading = el('summary', record.date); day.append(heading);
|
||||||
|
for (const [title, field] of [['已计划', 'planned'], ['实际执行', 'executed'], ['证据', 'evidence'], ['成本', 'costs'], ['复盘', 'review'], ['明日任务', 'tomorrow']]) day.append(listSection(title, record[field]));
|
||||||
|
host.append(day);
|
||||||
|
}
|
||||||
|
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
|
function renderEvolution(data) {
|
||||||
|
const host = $('#evolution-content'); host.replaceChildren();
|
||||||
|
const events = Array.isArray(data?.events) ? data.events : [];
|
||||||
|
if (!events.length) { host.append(el('p', '还没有演变记录。后续探索与决策会在这里归档。', 'empty')); return; }
|
||||||
|
const timeline = el('div', undefined, 'timeline');
|
||||||
|
for (const event of events) {
|
||||||
|
const item = el('details', undefined, 'evolution-event');
|
||||||
|
const summary = el('summary');
|
||||||
|
const heading = el('span', undefined, 'event-heading');
|
||||||
|
const precision = { datetime: '精确时间', date: '日期', approximate: '约略时间', unknown: '时间待确认' }[event.occurred_at_precision] || event.occurred_at_precision;
|
||||||
|
const timing = event.occurred_at ? `发生:${dateTime(event.occurred_at)}${precision ? `(${precision})` : ''} · ` : (precision ? `发生时间:${precision} · ` : '');
|
||||||
|
heading.append(el('strong', valueText(event.change || event.trigger || event.id)), el('span', `${timing}记录:${dateTime(event.recorded_at)} · ${event.retrospective === true ? '早期补录' : event.retrospective === false ? '实时记录' : '记录方式待确认'}`, 'muted'));
|
||||||
|
summary.append(heading); item.append(summary);
|
||||||
|
const fields = el('dl', undefined, 'event-fields');
|
||||||
|
for (const [label, value] of [['起因', event.trigger], ['假设', event.hypothesis], ['改变', event.change], ['证据', event.evidence], ['已知成本', event.cost?.known], ['待确认成本', event.cost?.unknown], ['决策', event.decision], ['下一步', event.next_step], ['关联版本与资料', event.artifact_refs]]) {
|
||||||
|
const row = el('div'); row.append(el('dt', label), el('dd', valueText(value))); fields.append(row);
|
||||||
|
}
|
||||||
|
item.append(fields); timeline.append(item);
|
||||||
|
}
|
||||||
|
host.append(timeline);
|
||||||
|
}
|
||||||
|
async function openEvolution() {
|
||||||
|
showView('evolution');
|
||||||
|
const host = $('#evolution-content'); host.replaceChildren(el('p', '正在读取演变记录…', 'muted'));
|
||||||
|
history.replaceState(null, '', `/?view=evolution${currentReportId ? `&report=${encodeURIComponent(currentReportId)}` : ''}`);
|
||||||
|
try { renderEvolution(await api('/api/evolution')); }
|
||||||
|
catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||||
|
}
|
||||||
async function openReport(id) {
|
async function openReport(id) {
|
||||||
|
showView('report');
|
||||||
$('#global-error').textContent = '';
|
$('#global-error').textContent = '';
|
||||||
try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); }
|
try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); }
|
||||||
catch (error) { $('#global-error').textContent = error.message; }
|
catch (error) { $('#global-error').textContent = error.message; }
|
||||||
@@ -120,16 +222,28 @@ async function loadDashboard() {
|
|||||||
}
|
}
|
||||||
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
|
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
|
||||||
renderLedger();
|
renderLedger();
|
||||||
const selected = new URL(location.href).searchParams.get('report');
|
const params = new URL(location.href).searchParams;
|
||||||
|
const selected = params.get('report');
|
||||||
|
const initialView = params.get('view');
|
||||||
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
|
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
|
||||||
|
if (initialView === 'evolution') await openEvolution();
|
||||||
|
if (initialView === 'daily') await openDaily();
|
||||||
|
if (initialView === 'execution') await openExecution(false);
|
||||||
|
if (!selected && !initialView) await openOverview();
|
||||||
}
|
}
|
||||||
|
$('#show-execution').addEventListener('click', () => openExecution());
|
||||||
|
setInterval(() => { if (dashboard && !$('#execution-view').hidden) openExecution(false); }, 60000);
|
||||||
|
$('#show-overview').addEventListener('click', openOverview);
|
||||||
|
$('#show-evolution').addEventListener('click', openEvolution);
|
||||||
|
$('#show-daily').addEventListener('click', openDaily);
|
||||||
|
$('#show-report').addEventListener('click', () => { showView('report'); history.replaceState(null, '', currentReportId ? `/?report=${encodeURIComponent(currentReportId)}` : '/'); });
|
||||||
$('#login-form').addEventListener('submit', async event => {
|
$('#login-form').addEventListener('submit', async event => {
|
||||||
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
|
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
|
||||||
try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); }
|
try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); }
|
||||||
catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; }
|
catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; }
|
||||||
});
|
});
|
||||||
$('#logout').addEventListener('click', async () => {
|
$('#logout').addEventListener('click', async () => {
|
||||||
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
|
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#evolution-content').replaceChildren(); $('#daily-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
|
||||||
catch (error) { $('#global-error').textContent = error.message; }
|
catch (error) { $('#global-error').textContent = error.message; }
|
||||||
});
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div id="dashboard-view" hidden>
|
<div id="dashboard-view" hidden>
|
||||||
<header class="topbar"><a class="wordmark" href="/">pawplay<span>运营台</span></a><div class="top-right"><span class="private-pill">私有档案</span><button id="logout" class="quiet">退出</button></div></header>
|
<header class="topbar"><a class="wordmark" href="/">pawplay<span>运营台</span></a><div class="top-right"><span class="private-pill">私有档案</span><button id="logout" class="quiet">退出</button></div></header>
|
||||||
<div class="workspace"><aside class="sidebar"><div class="sidebar-heading"><p class="eyebrow">每周运营</p><h2>进展档案</h2></div><nav id="report-nav" aria-label="历史周报"></nav><div class="sidebar-foot"><span class="status-dot"></span>北京时间归档<br><span class="muted">费用与收益独立核算</span></div></aside>
|
<div class="workspace"><aside class="sidebar"><div class="sidebar-heading"><p class="eyebrow">每周运营</p><h2>进展档案</h2></div><nav id="report-nav" aria-label="历史周报"></nav><div class="sidebar-foot"><span class="status-dot"></span>北京时间归档<br><span class="muted">费用与收益独立核算</span></div></aside>
|
||||||
<main class="main"><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
|
<main class="main"><nav class="view-tabs" aria-label="浏览内容"><button id="show-overview" class="active" type="button" aria-pressed="true">运营全景</button><button id="show-execution" type="button" aria-pressed="false">运行与历史</button><button id="show-report" type="button" aria-pressed="false">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button><button id="show-daily" type="button" aria-pressed="false">每日计划与复盘</button></nav><section id="overview-view"><div class="page-heading"><div><p class="eyebrow">XINGQI · OPERATIONS</p><h1>星栖运营全景</h1><p class="muted">方向、作品、研究和预算,放在一起看。</p></div></div><div id="overview-content"></div></section><section id="execution-view" hidden><div class="page-heading"><div><p class="eyebrow">XINGQI · EXECUTION</p><h1>运行与历史</h1><p class="muted">区分实际运行、等待执行和已结束的工作。</p></div></div><div id="execution-content"></div></section><div id="report-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section></div><section id="evolution-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · EVOLUTION</p><h1>演变路径</h1><p class="muted">从起因到下一步,回看每次探索的依据与决定。</p></div></div><div id="evolution-content"></div></section><section id="daily-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · DAILY</p><h1>每日计划与复盘</h1><p class="muted">按日期查看计划、执行、证据与下一步。</p></div></div><div id="daily-content"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="loading" class="loading" role="status">正在打开运营台…</p>
|
<p id="loading" class="loading" role="status">正在打开运营台…</p>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -67,6 +67,31 @@ export function createApp(options = process.env) {
|
|||||||
return results.sort((a, b) => String(b.period_end || b.id).localeCompare(String(a.period_end || a.id)));
|
return results.sort((a, b) => String(b.period_end || b.id).localeCompare(String(a.period_end || a.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dailyRecords() {
|
||||||
|
let directory;
|
||||||
|
try { directory = await realpath(path.join(dataDir, 'daily')); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') return { records: [], invalid_count: 0 }; throw error; }
|
||||||
|
const actualRoot = await realpath(dataDir);
|
||||||
|
if (!directory.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR');
|
||||||
|
const names = await readdir(directory);
|
||||||
|
const fields = ['planned', 'executed', 'evidence', 'costs', 'review', 'tomorrow'];
|
||||||
|
const records = []; let invalidCount = 0;
|
||||||
|
for (const name of names.filter(n => /^\d{4}-\d{2}-\d{2}\.json$/.test(n)).sort().reverse()) {
|
||||||
|
const day = name.slice(0, -5);
|
||||||
|
const parsed = new Date(day + 'T00:00:00Z');
|
||||||
|
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) { invalidCount++; continue; }
|
||||||
|
try {
|
||||||
|
const item = await jsonFile(`daily/${name}`, null);
|
||||||
|
if (!item || Array.isArray(item) || typeof item !== 'object' || item.date !== day ||
|
||||||
|
fields.some(field => item[field] !== undefined && (!Array.isArray(item[field]) || item[field].some(value => typeof value !== 'string')))) {
|
||||||
|
invalidCount++; continue;
|
||||||
|
}
|
||||||
|
records.push({ date: day, ...Object.fromEntries(fields.map(field => [field, item[field] || []])) });
|
||||||
|
} catch { invalidCount++; }
|
||||||
|
}
|
||||||
|
return { records, invalid_count: invalidCount };
|
||||||
|
}
|
||||||
|
|
||||||
function json(res, status, body, extra = {}) {
|
function json(res, status, body, extra = {}) {
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra });
|
||||||
res.end(JSON.stringify(body));
|
res.end(JSON.stringify(body));
|
||||||
@@ -129,6 +154,25 @@ export function createApp(options = process.env) {
|
|||||||
}
|
}
|
||||||
if (route.startsWith('/api/')) {
|
if (route.startsWith('/api/')) {
|
||||||
if (!session(req)) return json(res, 401, { error: '请先登录' });
|
if (!session(req)) return json(res, 401, { error: '请先登录' });
|
||||||
|
if (req.method === 'GET' && route === '/api/execution') return json(res, 200, await jsonFile('ops-status.json', { schema: 1, observed_at: null, units: [], running_tasks: [], history: [] }));
|
||||||
|
if (req.method === 'GET' && route === '/api/overview') {
|
||||||
|
const strategy = await jsonFile('ops-strategy.json', {}), market = await jsonFile('market-latest.json', {});
|
||||||
|
const short = (v, n = 300) => typeof v === 'string' ? v.slice(0, n) : '';
|
||||||
|
const video = v => ({ bvid: /^BV[0-9A-Za-z]+$/.test(v?.bvid || '') ? v.bvid : '', title: short(v?.title, 150), owner: short(v?.owner, 80), views: Number.isFinite(v?.views) ? v.views : null, likes: Number.isFinite(v?.likes) ? v.likes : null, comments: Number.isFinite(v?.comments) ? v.comments : null });
|
||||||
|
return json(res, 200, { strategy: { mission: short(strategy.mission), pillars: Array.isArray(strategy.pillars) ? strategy.pillars.slice(0, 6).map(v => short(v)) : [], cadence: short(strategy.cadence), phase: short(strategy.phase), budget_note: short(strategy.budget_note), budget: Array.isArray(strategy.budget) ? strategy.budget.slice(0, 8).map(v => ({ name: short(v?.name, 50), percent: Number.isFinite(v?.percent) ? v.percent : null })) : [] }, market: { observed_at: short(market.observed_at), ranking: Array.isArray(market.ranking) ? market.ranking.slice(0, 5).map(video) : [], account_videos: Array.isArray(market.account_videos) ? market.account_videos.slice(0, 20).map(video) : [] } });
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && route === '/api/workqueue') {
|
||||||
|
const value = await jsonFile('workqueue.json', { schema: 1, tasks: [] });
|
||||||
|
if (value?.schema !== 1 || !Array.isArray(value.tasks)) return json(res, 500, { error: '工作队列格式无效' });
|
||||||
|
const valid = new Set(['pending', 'running', 'waiting', 'done', 'blocked']);
|
||||||
|
const categories = new Set(['content', 'engineering', 'research', 'community', 'management']);
|
||||||
|
const tasks = value.tasks.filter(t => t && typeof t.id === 'string' && typeof t.title === 'string' && valid.has(t.status))
|
||||||
|
.map(t => ({ title: t.id.startsWith('interaction-') ? '核查新互动' : t.title.slice(0, 100), status: t.status === 'pending' && t.dispatch_ready !== true ? 'waiting' : t.status,
|
||||||
|
category: categories.has(t.category) ? t.category : 'management', attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null }));
|
||||||
|
return json(res, 200, { tasks });
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && route === '/api/evolution') return json(res, 200, await jsonFile('evolution.json', { schema_version: 1, events: [] }));
|
||||||
|
if (req.method === 'GET' && route === '/api/daily') return json(res, 200, await dailyRecords());
|
||||||
if (req.method === 'GET' && route === '/api/dashboard') {
|
if (req.method === 'GET' && route === '/api/dashboard') {
|
||||||
const list = await reports();
|
const list = await reports();
|
||||||
const config = await jsonFile('config.json', { title: 'PawPlay 运营台', timezone: 'Asia/Shanghai', budget: { billing_period: 'pending' } });
|
const config = await jsonFile('config.json', { title: 'PawPlay 运营台', timezone: 'Asia/Shanghai', budget: { billing_period: 'pending' } });
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ test('password hashing uses random salts and checks a supplied password', () =>
|
|||||||
|
|
||||||
test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => {
|
test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => {
|
||||||
const { request } = await fixture(t);
|
const { request } = await fixture(t);
|
||||||
for (const endpoint of ['/api/dashboard', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) {
|
for (const endpoint of ['/api/dashboard', '/api/evolution', '/api/workqueue', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) {
|
||||||
const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store');
|
const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store');
|
||||||
assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/);
|
assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/);
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,19 @@ test('anonymous users can see health/login shell but cannot read private reports
|
|||||||
assert.equal((await request('/reports/2026-W45.json')).status, 404);
|
assert.equal((await request('/reports/2026-W45.json')).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('work queue exposes only safe progress fields behind login', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({ schema: 1, tasks: [
|
||||||
|
{id:'interaction-private-a', title:'PRIVATE MESSAGE CONTENT', status:'pending', details:'PRIVATE MESSAGE', checkpoint:'PRIVATE CONTENT', attempts:1},
|
||||||
|
{id:'pilot',title:'动画试镜',status:'running',details:'SECRET',attempts:2,updated_at:'2026-11-04T01:00:00Z'}
|
||||||
|
] }));
|
||||||
|
assert.equal((await request('/api/workqueue')).status, 401);
|
||||||
|
const auth = await login(); const cookie = auth.headers.get('set-cookie').split(';')[0];
|
||||||
|
const response = await request('/api/workqueue', { headers: { cookie } });
|
||||||
|
const body = await response.text(); assert.doesNotMatch(body, /PRIVATE|SECRET/);
|
||||||
|
const data = JSON.parse(body); assert.equal(data.tasks[0].title, '核查新互动'); assert.equal(data.tasks[1].status, 'running');
|
||||||
|
});
|
||||||
|
|
||||||
test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => {
|
test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => {
|
||||||
const { request, login } = await fixture(t);
|
const { request, login } = await fixture(t);
|
||||||
const response = await login(); assert.equal(response.status, 200);
|
const response = await login(); assert.equal(response.status, 200);
|
||||||
@@ -110,3 +123,90 @@ test('invalid JSON values and oversized bodies are rejected', async t => {
|
|||||||
assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400);
|
assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400);
|
||||||
assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413);
|
assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('evolution is private, readable after login, and empty when absent', async t => {
|
||||||
|
const { request, login, directory } = await fixture(t);
|
||||||
|
const example = { schema_version: 1, events: [{ id: 'fictional-1', recorded_at: '2026-11-03', retrospective: true, trigger: '虚构起因', hypothesis: '虚构假设', change: '虚构改变', evidence: ['虚构证据'], cost: { known: '未知', unknown: '待确认' }, decision: '虚构决策', next_step: '虚构下一步', artifact_refs: ['虚构版本'] }] };
|
||||||
|
await writeFile(path.join(directory, 'evolution.json'), JSON.stringify(example));
|
||||||
|
const anonymous = await request('/api/evolution'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /虚构/);
|
||||||
|
const shell = await request('/'); assert.doesNotMatch(await shell.text(), /虚构/);
|
||||||
|
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const authenticated = await request('/api/evolution', { headers: { Cookie: cookie } });
|
||||||
|
assert.equal(authenticated.status, 200); assert.deepEqual(await authenticated.json(), example);
|
||||||
|
await rm(path.join(directory, 'evolution.json'));
|
||||||
|
const empty = await request('/api/evolution', { headers: { Cookie: cookie } });
|
||||||
|
assert.equal(empty.status, 200); assert.deepEqual(await empty.json(), { schema_version: 1, events: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily records require authentication, sort by date, validate input and remain read-only', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await mkdir(path.join(directory, 'daily'));
|
||||||
|
const write = (name, value) => writeFile(path.join(directory, 'daily', name), value);
|
||||||
|
await write('2026-11-02.json', JSON.stringify({ date: '2026-11-02', planned: ['<script>private</script>'], costs: ['未知'] }));
|
||||||
|
await write('2026-11-03.json', JSON.stringify({ date: '2026-11-03', executed: ['完成'], evidence: ['证据'] }));
|
||||||
|
await write('2026-11-04.json', '{broken');
|
||||||
|
await write('2026-11-05.json', JSON.stringify({ date: '2026-11-05', planned: 'wrong type' }));
|
||||||
|
await write('2026-02-30.json', JSON.stringify({ date: '2026-02-30' }));
|
||||||
|
await write('ignored.json', 'bad');
|
||||||
|
const anonymous = await request('/api/daily'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /private|完成/);
|
||||||
|
assert.equal((await request('/daily/2026-11-02.json')).status, 404);
|
||||||
|
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const headers = { Cookie: cookie };
|
||||||
|
const result = await request('/api/daily', { headers }); assert.equal(result.status, 200);
|
||||||
|
const body = await result.json(); assert.equal(body.invalid_count, 3);
|
||||||
|
assert.deepEqual(body.records.map(r => r.date), ['2026-11-03', '2026-11-02']);
|
||||||
|
assert.deepEqual(body.records[0].planned, []); assert.deepEqual(body.records[0].executed, ['完成']);
|
||||||
|
assert.deepEqual(body.records[1].costs, ['未知']);
|
||||||
|
assert.equal((await request('/api/daily', { method: 'POST', headers: { ...headers, Origin: origin }, body: '{}' })).status, 404);
|
||||||
|
await rm(path.join(directory, 'daily'), { recursive: true });
|
||||||
|
assert.deepEqual(await (await request('/api/daily', { headers })).json(), { records: [], invalid_count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('daily symlinks cannot disclose files outside DATA_DIR', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-daily-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
|
||||||
|
await mkdir(path.join(directory, 'daily'));
|
||||||
|
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ date: '2026-11-03', executed: ['OUTSIDE-SECRET'] }));
|
||||||
|
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'daily/2026-11-03.json'));
|
||||||
|
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const response = await request('/api/daily', { headers: { Cookie: cookie } });
|
||||||
|
assert.equal(response.status, 200); const text = await response.text(); assert.doesNotMatch(text, /OUTSIDE-SECRET/);
|
||||||
|
assert.deepEqual(JSON.parse(text), { records: [], invalid_count: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overview is authenticated and exposes only selected fields', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await writeFile(path.join(directory, 'ops-strategy.json'), JSON.stringify({ mission:'星栖', private:'SECRET' }));
|
||||||
|
await writeFile(path.join(directory, 'market-latest.json'), JSON.stringify({ observed_at:'2026-11-04', ranking:[{bvid:'https://evil.test',title:'榜单',views:1,private:'SECRET'}],account_videos:[] }));
|
||||||
|
assert.equal((await request('/api/overview')).status, 401);
|
||||||
|
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const response = await request('/api/overview', { headers:{cookie} }); const text = await response.text();
|
||||||
|
assert.doesNotMatch(text, /SECRET|evil/); const data = JSON.parse(text);
|
||||||
|
assert.equal(data.strategy.mission, '星栖'); assert.equal(data.market.ranking[0].bvid, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('execution status is private and absent data degrades safely', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await writeFile(path.join(directory, 'ops-status.json'), JSON.stringify({schema:1,observed_at:'2026-11-04T04:00:00Z',units:[],running_tasks:[],history:[{title:'任务',status:'complete'}]}));
|
||||||
|
assert.equal((await request('/api/execution')).status, 401);
|
||||||
|
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const data = await (await request('/api/execution', { headers:{cookie} })).json();
|
||||||
|
assert.equal(data.history[0].title, '任务'); assert.equal(data.history[0].status, 'complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting queue tasks remain visible to the owner', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await writeFile(path.join(directory, 'workqueue.json'), JSON.stringify({schema:1,tasks:[{id:'idea',title:'等待素材',status:'waiting'}]}));
|
||||||
|
const cookie=(await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const result=await (await request('/api/workqueue',{headers:{cookie}})).json();
|
||||||
|
assert.equal(result.tasks[0].status,'waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy pending tasks are not presented as ready to dispatch', async t => {
|
||||||
|
const { directory, request, login } = await fixture(t);
|
||||||
|
await writeFile(path.join(directory,'workqueue.json'),JSON.stringify({schema:1,tasks:[{id:'old',title:'旧任务',status:'pending'}]}));
|
||||||
|
const cookie=(await login()).headers.get('set-cookie').split(';')[0];
|
||||||
|
const value=await (await request('/api/workqueue',{headers:{cookie}})).json();
|
||||||
|
assert.equal(value.tasks[0].status,'waiting');
|
||||||
|
});
|
||||||
|
|||||||
+77
-3
@@ -117,6 +117,68 @@ def codex_usage(session_root, thread_id, start, end):
|
|||||||
'measurement_note': '会话日志计数含重复处理的上下文和缓存;不是唯一文字数、周额度百分比或API账单。推理token包含于输出计数,不重复相加。'}
|
'measurement_note': '会话日志计数含重复处理的上下文和缓存;不是唯一文字数、周额度百分比或API账单。推理token包含于输出计数,不重复相加。'}
|
||||||
|
|
||||||
|
|
||||||
|
def scheduled_codex_usage(directory, start, end):
|
||||||
|
"""Read bounded cycle summaries separately from the interactive thread."""
|
||||||
|
if not directory or not Path(directory).is_dir():
|
||||||
|
return {'available': False, 'reason': '定时运营汇总目录未配置或不可用'}
|
||||||
|
totals = {k: 0 for k in ('input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens')}
|
||||||
|
api_totals = dict.fromkeys(totals, 0)
|
||||||
|
runs = failed = 0
|
||||||
|
categories = {}
|
||||||
|
api_runs = api_uncertain = api_usage_events = 0
|
||||||
|
for path in Path(directory).glob('*.summary.json'):
|
||||||
|
try:
|
||||||
|
record = json.loads(path.read_text())
|
||||||
|
stamp = record.get('started_at')
|
||||||
|
when = dt.datetime.fromisoformat(stamp.replace('Z', '+00:00')) if stamp else dt.datetime.strptime(record['id'][:16], '%Y%m%dT%H%M%SZ').replace(tzinfo=dt.timezone.utc)
|
||||||
|
if when.tzinfo is None or not start <= when < end:
|
||||||
|
continue
|
||||||
|
runs += 1
|
||||||
|
failed += record.get('status') != 'complete'
|
||||||
|
if record.get('auth') == 'api_key':
|
||||||
|
api_runs += record.get('api_fallback_used') is True
|
||||||
|
api_uncertain += record.get('api_fallback_used') is None
|
||||||
|
for usage in record.get('usage', []):
|
||||||
|
api_usage_events += 1
|
||||||
|
for k in api_totals:
|
||||||
|
api_totals[k] += max(0, int(usage.get(k, 0)))
|
||||||
|
continue
|
||||||
|
category = record.get('category') if record.get('category') in ('content', 'engineering', 'research', 'community', 'management') else 'unattributed'
|
||||||
|
lane = categories.setdefault(category, {'runs': 0, 'elapsed_seconds': 0, 'elapsed_missing': 0, **dict.fromkeys(totals, 0)})
|
||||||
|
lane['runs'] += 1
|
||||||
|
if isinstance(record.get('elapsed_seconds'), (int, float)) and record['elapsed_seconds'] >= 0: lane['elapsed_seconds'] += record['elapsed_seconds']
|
||||||
|
else: lane['elapsed_missing'] += 1
|
||||||
|
for usage in record.get('usage', []):
|
||||||
|
for k in totals:
|
||||||
|
amount = max(0, int(usage.get(k, 0)))
|
||||||
|
totals[k] += amount
|
||||||
|
lane[k] += amount
|
||||||
|
except (ValueError, OSError, KeyError, TypeError, AttributeError):
|
||||||
|
continue
|
||||||
|
return {'available': True, 'runs': runs, 'failed_runs': failed, 'api_runs_separate': api_runs, 'api_runs_uncertain': api_uncertain, 'api_observed_usage': api_totals if api_usage_events else None, 'by_category': categories, **totals,
|
||||||
|
'measurement_note': '来自独立定时会话的运行汇总,与交互会话分开统计;API 备用调用不计入订阅 token,实际成本单独核算。失败或中断可能没有完整用量。'}
|
||||||
|
|
||||||
|
|
||||||
|
def harness_run_usage(path, start, end):
|
||||||
|
if not Path(path).exists():
|
||||||
|
return {'available': False, 'reason': '后续 Harness 调用账本尚未创建'}
|
||||||
|
result = {'available': True, 'runs': 0, 'input_tokens': 0, 'output_tokens': 0, 'cash_settlement_usd': None}
|
||||||
|
for line in Path(path).read_text().splitlines():
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
when = dt.datetime.fromisoformat(row['at_utc'].replace('Z', '+00:00'))
|
||||||
|
if when.tzinfo is None or not start <= when < end:
|
||||||
|
continue
|
||||||
|
result['runs'] += 1
|
||||||
|
for usage in row.get('usage') or []:
|
||||||
|
result['input_tokens'] += max(0, int(usage.get('inputTokens', 0)))
|
||||||
|
result['output_tokens'] += max(0, int(usage.get('outputTokens', 0)))
|
||||||
|
except (ValueError, KeyError, TypeError, AttributeError):
|
||||||
|
continue
|
||||||
|
result['measurement_note'] = '只统计自动账本创建后的调用;实际结算未知,不与历史快照重复计费。'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def build_report(data_dir, snapshot=False, now=None):
|
def build_report(data_dir, snapshot=False, now=None):
|
||||||
now = now or dt.datetime.now(TZ)
|
now = now or dt.datetime.now(TZ)
|
||||||
now = now.astimezone(TZ)
|
now = now.astimezone(TZ)
|
||||||
@@ -131,6 +193,7 @@ def build_report(data_dir, snapshot=False, now=None):
|
|||||||
ledger = read_json(data_dir / 'ledger.json', {'entries': []})
|
ledger = read_json(data_dir / 'ledger.json', {'entries': []})
|
||||||
content = read_json(data_dir / 'content.json', {'items': []})
|
content = read_json(data_dir / 'content.json', {'items': []})
|
||||||
tasks = read_json(data_dir / 'tasks.json', {'items': []})
|
tasks = read_json(data_dir / 'tasks.json', {'items': []})
|
||||||
|
queue = read_json(data_dir / 'workqueue.json', None)
|
||||||
findings = read_json(data_dir / 'findings.json', {})
|
findings = read_json(data_dir / 'findings.json', {})
|
||||||
api = read_json(data_dir / 'deepseek-usage.json', {})
|
api = read_json(data_dir / 'deepseek-usage.json', {})
|
||||||
entries = []
|
entries = []
|
||||||
@@ -168,6 +231,15 @@ def build_report(data_dir, snapshot=False, now=None):
|
|||||||
if not observed_this_period(findings.get('checked_at')):
|
if not observed_this_period(findings.get('checked_at')):
|
||||||
risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。')
|
risks.append('基础验收记录未在本周期更新,历史成果未重复计作本周完成。')
|
||||||
current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), None)
|
current = next((i for i in tasks.get('items', []) if i.get('status') == 'in_progress'), None)
|
||||||
|
next_actions = [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8]
|
||||||
|
# A task queue is a current snapshot; it does not reconstruct the past reporting period.
|
||||||
|
if isinstance(queue, dict) and queue.get('schema') == 1 and isinstance(queue.get('tasks'), list):
|
||||||
|
safe = [i for i in queue['tasks'] if isinstance(i, dict) and i.get('status') in ('pending', 'running', 'waiting', 'blocked') and isinstance(i.get('title'), str) and isinstance(i.get('id'), str)]
|
||||||
|
safe = [{**i, 'status': 'waiting' if i['status'] == 'pending' and i.get('dispatch_ready') is not True else i['status']} for i in safe]
|
||||||
|
safe.sort(key=lambda i: (0 if i['status'] == 'running' else 1 if i['status'] == 'pending' else 2 if i['status'] == 'waiting' else 3, i.get('updated_at') or ''))
|
||||||
|
next_actions = [('核查新互动' if i['id'].startswith('interaction-') else i['title'][:100]) + ('(已阻塞)' if i['status'] == 'blocked' else '(等待触发条件)' if i['status'] == 'waiting' else '') for i in safe[:8]]
|
||||||
|
current = next(({'title': '核查新互动' if i['id'].startswith('interaction-') else i['title'][:100]} for i in safe if i['status'] == 'running'), None)
|
||||||
|
|
||||||
identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '')
|
identifier = end.strftime('%G-W%V') + ('-snapshot-' + now.strftime('%Y%m%dT%H%M%S') if snapshot else '')
|
||||||
report = {
|
report = {
|
||||||
'id': identifier,
|
'id': identifier,
|
||||||
@@ -177,15 +249,17 @@ def build_report(data_dir, snapshot=False, now=None):
|
|||||||
'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。',
|
'summary': ('当前推进:' + current['title']) if current else '根据已归档的项目记录汇总;缺失数据保留待核算。',
|
||||||
'highlights': highlights,
|
'highlights': highlights,
|
||||||
'risks': risks,
|
'risks': risks,
|
||||||
'next_actions': [i['title'] for i in tasks.get('items', []) if i.get('status') in ('todo', 'in_progress')][:8],
|
'next_actions': next_actions,
|
||||||
'decisions': findings.get('decisions', []),
|
'decisions': findings.get('decisions', []),
|
||||||
'metrics': {
|
'metrics': {
|
||||||
'published_project_videos': len(published),
|
'published_project_videos': len(published),
|
||||||
'project_video_views': 0 if not published else None,
|
'project_video_views': 0 if not published else None,
|
||||||
'project_game_sessions': None,
|
'project_game_sessions': None,
|
||||||
'codex': usage,
|
'codex': usage,
|
||||||
|
'scheduled_codex': scheduled_codex_usage(collector.get('ops_cycle_summaries_dir'), begin_stamp, end_stamp),
|
||||||
'deepseek': api or {'status': '未采集'},
|
'deepseek': api or {'status': '未采集'},
|
||||||
'measurement_notes': ['只统计运营账本登记稿件,不计入账户其他历史内容。', '当前会话包含PawPlay、运营基础设施和虚拟人设研究,token仅能按该共享会话汇总,未精确归因到单个方向。', '游戏离线运行且未接入统计,不能推导活跃、留存或转化率。', '已发布稿件存在时,播放数据需接入稿件快照采集,当前不填造数值。']
|
'harness_new_runs': harness_run_usage(data_dir / 'harness-runs.jsonl', begin_stamp, end_stamp),
|
||||||
|
'measurement_notes': ['只统计运营账本登记稿件,不计入账户其他历史内容。', '当前会话包含PawPlay、运营基础设施和虚拟人设研究,token仅能按该共享会话汇总,未精确归因到单个方向。定时任务作为独立会话分别统计。', '游戏离线运行且未接入统计,不能推导活跃、留存或转化率。', '已发布稿件存在时,播放数据需接入稿件快照采集,当前不填造数值。']
|
||||||
},
|
},
|
||||||
'costs': {
|
'costs': {
|
||||||
'cash_usd': round(sum(e['amount'] for e in costs), 2) if config.get('cash_tracking_complete') else None,
|
'cash_usd': round(sum(e['amount'] for e in costs), 2) if config.get('cash_tracking_complete') else None,
|
||||||
@@ -196,7 +270,7 @@ def build_report(data_dir, snapshot=False, now=None):
|
|||||||
},
|
},
|
||||||
'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None,
|
'revenue': {'settled_cny': round(sum(e['amount'] for e in incomes), 2) if incomes else None, 'estimated_cny': None,
|
||||||
'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']},
|
'notes': ['播放量不是结算收入,自有产品推广稿件是否纳入激励需单独验证。', '未结算或未归因到PawPlay的收益不算项目收入。']},
|
||||||
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
|
'sources': {'findings_checked_at': findings.get('checked_at'), 'ledger_entries_in_period': len(entries), 'content_ledger': 'content.json', 'next_actions_source': 'workqueue.json:报告生成时的任务快照' if isinstance(queue, dict) and queue.get('schema') == 1 and isinstance(queue.get('tasks'), list) else 'tasks.json:静态任务清单', 'usage_source': '本机会话计数(只保存汇总,不发布对话)'},
|
||||||
}
|
}
|
||||||
repo = collector.get('repository')
|
repo = collector.get('repository')
|
||||||
if repo:
|
if repo:
|
||||||
|
|||||||
@@ -53,6 +53,40 @@ class AccountingTests(unittest.TestCase):
|
|||||||
self.assertFalse(created)
|
self.assertFalse(created)
|
||||||
self.assertEqual(original, second.read_bytes())
|
self.assertEqual(original, second.read_bytes())
|
||||||
|
|
||||||
|
def test_scheduled_usage_is_period_scoped_and_preserves_failed_runs(self):
|
||||||
|
import json
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / 'one.summary.json').write_text(json.dumps({'id':'20261103T141341Z-a','status':'complete','usage':[{'input_tokens':100,'cached_input_tokens':80,'output_tokens':10}]}))
|
||||||
|
(root / 'two.summary.json').write_text(json.dumps({'id':'20261104T141341Z-b','status':'failed','usage':[]}))
|
||||||
|
(root / 'paid.summary.json').write_text(json.dumps({'id':'20261104T151341Z-d','status':'complete','auth':'api_key','api_fallback_used':True,'usage':[{'input_tokens':33,'output_tokens':5}]}))
|
||||||
|
(root / 'old.summary.json').write_text(json.dumps({'id':'20261003T141341Z-c','status':'complete','usage':[{'input_tokens':900}]}))
|
||||||
|
value=report.scheduled_codex_usage(root,dt.datetime(2026,11,3,tzinfo=report.TZ),dt.datetime(2026,11,5,tzinfo=report.TZ))
|
||||||
|
self.assertEqual((value['runs'],value['failed_runs'],value['input_tokens'],value['cached_input_tokens']), (3,1,100,80))
|
||||||
|
self.assertEqual(value['api_runs_separate'],1);self.assertEqual(value['api_observed_usage']['input_tokens'],33)
|
||||||
|
|
||||||
|
def test_harness_ledger_counts_only_selected_period(self):
|
||||||
|
import json
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'harness-runs.jsonl'
|
||||||
|
entries = [{'at_utc':'2026-11-03T14:00:00Z','usage':[{'inputTokens':10,'outputTokens':4}]}, {'at_utc':'2026-10-03T14:00:00Z','usage':[{'inputTokens':100}]}]
|
||||||
|
path.write_text('\n'.join(json.dumps(e) for e in entries))
|
||||||
|
value=report.harness_run_usage(path,dt.datetime(2026,11,3,tzinfo=report.TZ),dt.datetime(2026,11,5,tzinfo=report.TZ))
|
||||||
|
self.assertEqual((value['runs'],value['input_tokens'],value['output_tokens']),(1,10,4))
|
||||||
|
self.assertIsNone(value['cash_settlement_usd'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_uses_current_queue_and_redacts_interaction_titles(self):
|
||||||
|
import json
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / 'tasks.json').write_text(json.dumps({'items':[{'title':'旧计划','status':'todo'}]}))
|
||||||
|
(root / 'workqueue.json').write_text(json.dumps({'schema':1,'tasks':[{'id':'interaction-private','title':'私人消息内容','status':'running'}, {'id':'video','title':'制作挑战视频','status':'pending','dispatch_ready':True}, {'id':'wait','title':'等待反馈','status':'waiting'}, {'id':'done','title':'已完成','status':'done'}]}))
|
||||||
|
path,_ = report.build_report(root, now=dt.datetime(2026,11,6,20,tzinfo=report.TZ))
|
||||||
|
value=report.read_json(path)
|
||||||
|
self.assertEqual(value['next_actions'],['核查新互动','制作挑战视频','等待反馈(等待触发条件)'])
|
||||||
|
self.assertNotIn('私人消息内容',json.dumps(value,ensure_ascii=False))
|
||||||
|
self.assertIn('workqueue.json',value['sources']['next_actions_source'])
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user