mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): record per-run token usage so operators can see what each template burns
This commit is contained in:
parent
e911af2192
commit
465a727be2
@ -58,6 +58,15 @@ public class WikiTransformationRunEntity {
|
||||
*/
|
||||
private Long outputPageId;
|
||||
|
||||
/** Prompt-side tokens reported by the provider (Spring AI Usage). */
|
||||
private Long inputTokens;
|
||||
|
||||
/** Completion-side tokens reported by the provider. */
|
||||
private Long outputTokens;
|
||||
|
||||
/** Provider's own total (usually input + output, but providers vary). */
|
||||
private Long totalTokens;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -227,9 +227,10 @@ public class WikiTransformationExecutor {
|
||||
ChatModel chatModel = buildChatModel(resolvedModelId);
|
||||
run.setModelId(resolvedModelId);
|
||||
|
||||
String output = callOnce(chatModel, systemPrompt, userPrompt);
|
||||
CallResult first = callOnce(chatModel, systemPrompt, userPrompt);
|
||||
accumulateUsage(run, first);
|
||||
if (wantJson) {
|
||||
String coerced = coerceToJson(output);
|
||||
String coerced = coerceToJson(first.text());
|
||||
if (coerced != null) {
|
||||
// Wrap in a fenced block so UI rendering and save-as-page
|
||||
// keep the existing markdown contract. The raw JSON is the
|
||||
@ -240,18 +241,22 @@ public class WikiTransformationExecutor {
|
||||
log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder",
|
||||
transformation.getName());
|
||||
String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。";
|
||||
String retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
||||
String coercedRetry = coerceToJson(retry);
|
||||
CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
||||
accumulateUsage(run, retry);
|
||||
String coercedRetry = coerceToJson(retry.text());
|
||||
if (coercedRetry != null) {
|
||||
return "```json\n" + coercedRetry + "\n```";
|
||||
}
|
||||
throw new IllegalStateException("LLM output is not valid JSON after one retry");
|
||||
}
|
||||
return output;
|
||||
return first.text();
|
||||
}
|
||||
|
||||
/** One LLM call, returns the cleaned output. Throws when the call yields blank. */
|
||||
private String callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) {
|
||||
/** Tuple returned from a single LLM call: cleaned text + usage (null when provider didn't surface usage). */
|
||||
private record CallResult(String text, Long inputTokens, Long outputTokens, Long totalTokens) {}
|
||||
|
||||
/** One LLM call, returns the cleaned output + provider usage. Throws when the call yields blank. */
|
||||
private CallResult callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) {
|
||||
ChatResponse resp = chatModel.call(new Prompt(List.of(
|
||||
new SystemMessage(systemPrompt), new UserMessage(userPrompt))));
|
||||
String rawOutput = (resp == null || resp.getResult() == null
|
||||
@ -264,7 +269,31 @@ public class WikiTransformationExecutor {
|
||||
if (output.isBlank()) {
|
||||
throw new IllegalStateException("LLM output was empty after cleanup");
|
||||
}
|
||||
return output;
|
||||
Long in = null, out = null, total = null;
|
||||
try {
|
||||
if (resp.getMetadata() != null && resp.getMetadata().getUsage() != null) {
|
||||
var u = resp.getMetadata().getUsage();
|
||||
in = u.getPromptTokens() == null ? null : u.getPromptTokens().longValue();
|
||||
out = u.getCompletionTokens() == null ? null : u.getCompletionTokens().longValue();
|
||||
total = u.getTotalTokens() == null ? null : u.getTotalTokens().longValue();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Usage extraction is best-effort — different providers expose it differently.
|
||||
}
|
||||
return new CallResult(output, in, out, total);
|
||||
}
|
||||
|
||||
/** Add provider-reported usage onto the run row (accumulates across retries). */
|
||||
private static void accumulateUsage(WikiTransformationRunEntity run, CallResult call) {
|
||||
if (call.inputTokens() != null) {
|
||||
run.setInputTokens((run.getInputTokens() == null ? 0L : run.getInputTokens()) + call.inputTokens());
|
||||
}
|
||||
if (call.outputTokens() != null) {
|
||||
run.setOutputTokens((run.getOutputTokens() == null ? 0L : run.getOutputTokens()) + call.outputTokens());
|
||||
}
|
||||
if (call.totalTokens() != null) {
|
||||
run.setTotalTokens((run.getTotalTokens() == null ? 0L : run.getTotalTokens()) + call.totalTokens());
|
||||
}
|
||||
}
|
||||
|
||||
private static final com.fasterxml.jackson.databind.ObjectMapper JSON_MAPPER =
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- Record per-run token usage so operators can see which templates burn the
|
||||
-- most tokens and which models produce the most expensive output. Spring AI
|
||||
-- surfaces the values via ChatResponseMetadata.getUsage(); the executor
|
||||
-- snapshots them into the run row after the LLM call.
|
||||
|
||||
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS input_tokens BIGINT NULL;
|
||||
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS output_tokens BIGINT NULL;
|
||||
ALTER TABLE mate_wiki_transformation_run ADD COLUMN IF NOT EXISTS total_tokens BIGINT NULL;
|
||||
@ -0,0 +1,19 @@
|
||||
-- Record per-run token usage. See h2 sibling for prose explanation.
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
|
||||
AND COLUMN_NAME = 'input_tokens');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN input_tokens BIGINT NULL', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
|
||||
AND COLUMN_NAME = 'output_tokens');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN output_tokens BIGINT NULL', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_wiki_transformation_run'
|
||||
AND COLUMN_NAME = 'total_tokens');
|
||||
SET @s := IF(@c = 0, 'ALTER TABLE mate_wiki_transformation_run ADD COLUMN total_tokens BIGINT NULL', 'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@ -95,6 +95,9 @@
|
||||
{{ rawTitleFor(run.rawId) }}
|
||||
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
|
||||
· {{ formatDuration(run.durationMs) }}
|
||||
<span v-if="run.totalTokens" class="run-tokens">
|
||||
· {{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="run.outputPageId" class="run-saved-badge">
|
||||
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
|
||||
@ -299,6 +302,9 @@ interface WikiTransformationRun {
|
||||
createTime: string
|
||||
triggeredBy: string
|
||||
outputPageId: number | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
totalTokens: number | null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -373,6 +379,13 @@ function formatDuration(ms: number | null): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function formatTokens(n: number | null): string {
|
||||
if (n == null) return '—'
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 1_000_000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'm'
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
if (!store.currentKB) return
|
||||
loading.value = true
|
||||
@ -746,6 +759,7 @@ onMounted(async () => {
|
||||
.run-status--running, .run-status--pending { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
|
||||
.run-status--cancelled { background: var(--mc-bg-muted); color: var(--mc-text-tertiary); }
|
||||
.run-meta { color: var(--mc-text-tertiary); }
|
||||
.run-tokens { color: var(--mc-text-tertiary); font-family: var(--mc-font-mono, ui-monospace, Menlo, monospace); font-size: 11px; }
|
||||
.run-output {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user