mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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;
|
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)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -227,9 +227,10 @@ public class WikiTransformationExecutor {
|
|||||||
ChatModel chatModel = buildChatModel(resolvedModelId);
|
ChatModel chatModel = buildChatModel(resolvedModelId);
|
||||||
run.setModelId(resolvedModelId);
|
run.setModelId(resolvedModelId);
|
||||||
|
|
||||||
String output = callOnce(chatModel, systemPrompt, userPrompt);
|
CallResult first = callOnce(chatModel, systemPrompt, userPrompt);
|
||||||
|
accumulateUsage(run, first);
|
||||||
if (wantJson) {
|
if (wantJson) {
|
||||||
String coerced = coerceToJson(output);
|
String coerced = coerceToJson(first.text());
|
||||||
if (coerced != null) {
|
if (coerced != null) {
|
||||||
// Wrap in a fenced block so UI rendering and save-as-page
|
// Wrap in a fenced block so UI rendering and save-as-page
|
||||||
// keep the existing markdown contract. The raw JSON is the
|
// 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",
|
log.info("[WikiTransformation] JSON parse failed for template={}; retrying with stricter reminder",
|
||||||
transformation.getName());
|
transformation.getName());
|
||||||
String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。";
|
String retryUserPrompt = userPrompt + "\n\n---\n\n上一次回复不是合法 JSON。请只返回一个合法 JSON 文档,前后不要有任何文字或代码块标记。";
|
||||||
String retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
CallResult retry = callOnce(chatModel, systemPrompt, retryUserPrompt);
|
||||||
String coercedRetry = coerceToJson(retry);
|
accumulateUsage(run, retry);
|
||||||
|
String coercedRetry = coerceToJson(retry.text());
|
||||||
if (coercedRetry != null) {
|
if (coercedRetry != null) {
|
||||||
return "```json\n" + coercedRetry + "\n```";
|
return "```json\n" + coercedRetry + "\n```";
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("LLM output is not valid JSON after one retry");
|
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. */
|
/** Tuple returned from a single LLM call: cleaned text + usage (null when provider didn't surface usage). */
|
||||||
private String callOnce(ChatModel chatModel, String systemPrompt, String userPrompt) {
|
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(
|
ChatResponse resp = chatModel.call(new Prompt(List.of(
|
||||||
new SystemMessage(systemPrompt), new UserMessage(userPrompt))));
|
new SystemMessage(systemPrompt), new UserMessage(userPrompt))));
|
||||||
String rawOutput = (resp == null || resp.getResult() == null
|
String rawOutput = (resp == null || resp.getResult() == null
|
||||||
@ -264,7 +269,31 @@ public class WikiTransformationExecutor {
|
|||||||
if (output.isBlank()) {
|
if (output.isBlank()) {
|
||||||
throw new IllegalStateException("LLM output was empty after cleanup");
|
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 =
|
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) }}
|
{{ rawTitleFor(run.rawId) }}
|
||||||
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
|
· {{ formatTimestamp(run.completedAt || run.startedAt || run.createTime) }}
|
||||||
· {{ formatDuration(run.durationMs) }}
|
· {{ formatDuration(run.durationMs) }}
|
||||||
|
<span v-if="run.totalTokens" class="run-tokens">
|
||||||
|
· {{ formatTokens(run.inputTokens) }}↑ / {{ formatTokens(run.outputTokens) }}↓
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="run.outputPageId" class="run-saved-badge">
|
<span v-if="run.outputPageId" class="run-saved-badge">
|
||||||
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
|
{{ t('wiki.transformations.savedAsPage') }} #{{ run.outputPageId }}
|
||||||
@ -299,6 +302,9 @@ interface WikiTransformationRun {
|
|||||||
createTime: string
|
createTime: string
|
||||||
triggeredBy: string
|
triggeredBy: string
|
||||||
outputPageId: number | null
|
outputPageId: number | null
|
||||||
|
inputTokens: number | null
|
||||||
|
outputTokens: number | null
|
||||||
|
totalTokens: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -373,6 +379,13 @@ function formatDuration(ms: number | null): string {
|
|||||||
return `${(ms / 1000).toFixed(1)}s`
|
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() {
|
async function loadAll() {
|
||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
loading.value = true
|
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--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-status--cancelled { background: var(--mc-bg-muted); color: var(--mc-text-tertiary); }
|
||||||
.run-meta { 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 {
|
.run-output {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user