fix: give final acceptance a shared retry budget and diagnostics
This commit is contained in:
1 parent
50638d4130
commit
32441e79c4
18 files changed
+779
-40
No files matched your search
+32
-1
@@ -1,5 +1,18 @@
|
||||
@if (sessionId()) {
|
||||
<section class="model-requests" aria-label="模型请求状态">
|
||||
@for (review of activeReviews(); track review.review_id) {
|
||||
<div class="request-live review-live" [class.stale]="unavailable()" aria-live="polite">
|
||||
<div class="request-heading">
|
||||
<span class="status-dot" [class.waiting]="!unavailable()"></span>
|
||||
<strong>{{ unavailable() ? '验收状态暂时无法更新' : reviewStage(review) }}</strong>
|
||||
<span class="elapsed">验收已用 {{ reviewElapsed(review) }} 秒</span>
|
||||
</div>
|
||||
@if (!unavailable()) {
|
||||
<p>完整结论确认前,测试仍为待判定。剩余验收预算 {{ reviewRemaining(review) }} 秒 / 总计 {{ review.total_seconds }} 秒。</p>
|
||||
<p class="notice">已发起 {{ review.request_count }} 次模型请求,已重试 {{ review.retry_count }} 次;重试复用已有证据。</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@for (request of active(); track request.attempt_id) {
|
||||
<div class="request-live" [class.retrying]="request.phase === 'retry_wait'" [class.stale]="unavailable()">
|
||||
<div class="request-heading" aria-live="polite">
|
||||
@@ -21,6 +34,23 @@
|
||||
@if (loading()) { <p class="notice">正在加载请求记录…</p> }
|
||||
@if (!loading() && !unavailable() && !requests().length) { <p class="notice">暂无请求诊断。旧任务没有记录的时间和用量无法补推。</p> }
|
||||
<div class="request-history">
|
||||
@for (review of reviews(); track review.review_id) {
|
||||
<details class="request-detail review-detail">
|
||||
<summary>{{ review.started_at * 1000 | date:'HH:mm:ss' }} · 最终验收 · {{ reviewStage(review) }}</summary>
|
||||
<dl>
|
||||
<dt>验收编号</dt><dd>{{ review.review_id }} · {{ review.attempt_id }}</dd>
|
||||
<dt>验收预算</dt><dd>总计 {{ review.total_seconds }} 秒 · 已用 {{ reviewElapsed(review) }} 秒</dd>
|
||||
<dt>模型请求</dt><dd>{{ review.request_count }} 次,实际重试 {{ review.retry_count }} 次;每个请求最多重试 {{ review.max_retries }} 次,总预算内执行</dd>
|
||||
<dt>开始时间</dt><dd>{{ review.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||
<dt>预算截止</dt><dd>{{ review.deadline_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||
<dt>结束时间</dt><dd>{{ review.ended_at ? (review.ended_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }}</dd>
|
||||
@if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> }
|
||||
@if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> }
|
||||
@if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> }
|
||||
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>验收被停止或指导中断,不作为请求超时重试。</dd> }
|
||||
</dl>
|
||||
</details>
|
||||
}
|
||||
@for (request of requests(); track request.attempt_id) {
|
||||
<details class="request-detail">
|
||||
<summary><span>{{ request.started_at * 1000 | date:'HH:mm:ss' }}</span> {{ request.model || '模型' }} · {{ role(request) }} · {{ phase(request) }} @if (!request.active && !request.ended_at) { <span>(未记录结束,当前未在执行)</span> } @if (request.is_fallback) { <span class="fallback">兜底</span> }</summary>
|
||||
@@ -29,6 +59,7 @@
|
||||
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
|
||||
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
|
||||
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
|
||||
@if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> }
|
||||
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
|
||||
<dt>有效输出片段</dt><dd>{{ request.output_chunks ?? '未记录' }}(客户端计数,与服务端 SSE 事件数口径不同)</dd>
|
||||
<dt>工具参数</dt><dd>{{ request.tool_argument_chars ?? '未记录' }} 字符 @for (tool of request.tools || []; track $index) { <span class="image-size">{{ tool.name || '工具名未提供' }} · {{ tool.argument_chars }} 字符</span> }</dd>
|
||||
@@ -39,7 +70,7 @@
|
||||
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
|
||||
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
|
||||
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
|
||||
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }}</dd> }
|
||||
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> }
|
||||
</dl>
|
||||
</details>
|
||||
}
|
||||
|
||||
+41
-1
@@ -1,7 +1,7 @@
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { LlmRequestStatusComponent, ModelRequest, requestHint } from './llm-request-status.component';
|
||||
import { FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, reviewStage } from './llm-request-status.component';
|
||||
|
||||
const waiting: ModelRequest = {
|
||||
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
|
||||
@@ -78,4 +78,44 @@ describe('模型请求诊断', () => {
|
||||
expect(text).toContain('本次工具尚未执行');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('无模型请求时仍展示验收阶段与总预算,耗尽后保留明确原因', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
const now = Date.now() / 1000;
|
||||
const review: FinalReview = {
|
||||
review_id: 'review-1', attempt_id: 'final#1', status: 'running', stage: 'reading_evidence', active: true,
|
||||
started_at: now - 20, deadline_at: now + 80, total_seconds: 100, elapsed_seconds: 20, remaining_seconds: 80,
|
||||
request_count: 2, retry_count: 1, max_retries: 5
|
||||
};
|
||||
fixture.componentRef.setInput('sessionId', 'review-task');
|
||||
fixture.componentRef.setInput('running', true);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [review] });
|
||||
fixture.detectChanges();
|
||||
const text = fixture.nativeElement.querySelector('.review-live').textContent;
|
||||
expect(text).toContain('正在读取验收证据');
|
||||
expect(text).toContain('测试仍为待判定');
|
||||
expect(text).toContain('剩余验收预算 80 秒');
|
||||
expect(text).toContain('已重试 1 次');
|
||||
tick(1000); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.review-live').textContent).toContain('剩余验收预算 79 秒');
|
||||
tick(2000);
|
||||
http.expectOne('/api/sessions/review-task/request-diagnostics').flush({ requests: [], final_reviews: [{
|
||||
...review, active: false, status: 'budget_exhausted', ended_at: now + 3, elapsed_seconds: 100
|
||||
}] });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.review-live')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('验收总预算已用尽');
|
||||
expect(fixture.nativeElement.textContent).toContain('未确认通过');
|
||||
fixture.destroy(); tick(3000);
|
||||
http.expectNone('/api/sessions/review-task/request-diagnostics');
|
||||
}));
|
||||
|
||||
it('区分重试耗尽、验收中断与完成,不把完成当成通过', () => {
|
||||
const review = { status: 'request_retries_exhausted' } as FinalReview;
|
||||
expect(reviewStage(review)).toBe('验收请求重试已用尽');
|
||||
expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断');
|
||||
expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成');
|
||||
});
|
||||
});
|
||||
+32
-2
@@ -14,6 +14,23 @@ export interface ModelRequest {
|
||||
tools?: { name: string | null; argument_chars: number }[];
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
|
||||
images: { width: number | null; height: number | null }[];
|
||||
final_review?: FinalReview;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface FinalReview {
|
||||
review_id: string; attempt_id: string; status: string; stage: string; active: boolean;
|
||||
started_at: number; deadline_at: number; ended_at?: number;
|
||||
total_seconds: number; elapsed_seconds: number; remaining_seconds: number;
|
||||
request_count: number; retry_count: number; request_attempt?: number; max_retries: number;
|
||||
request_phase?: string; last_failure_category?: string;
|
||||
}
|
||||
|
||||
export function reviewStage(review: FinalReview): string {
|
||||
if (review.status !== 'running') {
|
||||
return ({ completed: '验收完成', budget_exhausted: '验收总预算已用尽', request_retries_exhausted: '验收请求重试已用尽', cancelled: '验收已中断', failed: '验收未完成' } as Record<string, string>)[review.status] || '验收状态待核实';
|
||||
}
|
||||
return ({ preparing_evidence: '正在准备验收证据', reviewing_evidence: '正在核对证据与结论', reading_evidence: '正在读取验收证据', structuring_verdict: '正在整理验收结论', validating_verdict: '正在校验完整结论' } as Record<string, string>)[review.stage] || '正在进行最终验收';
|
||||
}
|
||||
|
||||
export function requestPhase(request: ModelRequest | string): string {
|
||||
@@ -46,20 +63,24 @@ export class LlmRequestStatusComponent {
|
||||
running = input(false);
|
||||
activeChange = output<boolean>();
|
||||
requests = signal<ModelRequest[]>([]);
|
||||
reviews = signal<FinalReview[]>([]);
|
||||
now = signal(Date.now() / 1000);
|
||||
unavailable = signal(false);
|
||||
loading = signal(false);
|
||||
active = computed(() => this.running() ? this.requests().filter(r => r.active) : []);
|
||||
activeReviews = computed(() => this.running() ? this.reviews().filter(r => r.active) : []);
|
||||
downloadUrl = computed(() => `/api/sessions/${encodeURIComponent(this.sessionId() || '')}/request-diagnostics?download=true`);
|
||||
phase = requestPhase;
|
||||
hint = requestHint;
|
||||
reviewStage = reviewStage;
|
||||
|
||||
constructor() {
|
||||
effect(() => this.activeChange.emit(this.active().length > 0));
|
||||
effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0));
|
||||
effect(onCleanup => {
|
||||
const id = this.sessionId();
|
||||
const running = this.running();
|
||||
this.requests.set([]);
|
||||
this.reviews.set([]);
|
||||
this.unavailable.set(false);
|
||||
this.loading.set(!!id);
|
||||
if (!id) return;
|
||||
@@ -68,9 +89,10 @@ export class LlmRequestStatusComponent {
|
||||
let subscription: Subscription | undefined;
|
||||
const schedule = () => { if (running && !stopped) timer = setTimeout(load, 3000); };
|
||||
const load = () => {
|
||||
subscription = this.http.get<{ requests: ModelRequest[] }>(`/api/sessions/${encodeURIComponent(id)}/request-diagnostics`).subscribe({
|
||||
subscription = this.http.get<{ requests: ModelRequest[]; final_reviews?: FinalReview[] }>(`/api/sessions/${encodeURIComponent(id)}/request-diagnostics`).subscribe({
|
||||
next: data => {
|
||||
this.requests.set(data.requests || []);
|
||||
this.reviews.set(data.final_reviews || []);
|
||||
this.unavailable.set(false);
|
||||
this.loading.set(false);
|
||||
schedule();
|
||||
@@ -88,6 +110,14 @@ export class LlmRequestStatusComponent {
|
||||
return Math.max(0, Math.floor(request.ended_at ? request.elapsed_seconds : this.now() - request.started_at));
|
||||
}
|
||||
|
||||
reviewElapsed(review: FinalReview): number {
|
||||
return Math.max(0, Math.floor(review.ended_at ? review.elapsed_seconds : this.now() - review.started_at));
|
||||
}
|
||||
|
||||
reviewRemaining(review: FinalReview): number {
|
||||
return Math.max(0, Math.ceil(review.deadline_at - this.now()));
|
||||
}
|
||||
|
||||
role(request: ModelRequest): string {
|
||||
return ({ FlashRunner: '任务执行', operator: '任务执行', planner: '规划', checker: '结果检查', outputter: '报告整理', step_summarizer: '步骤整理' } as Record<string, string>)[request.role] || request.role;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user