mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 01:15:18 +08:00
refactor(whiteboard): address code-review feedback
W1: drop stale "prior-state image" reference from conflict-block prose
(lib/orchestration/summarizers/whiteboard-conflicts.ts) — the image
feature was removed; the text now says "real visible problem on the
current board".
W3: type `thinking` on StatelessChatRequest instead of reading it through
a cast. `app/api/chat/route.ts` now accesses `body.thinking` directly;
frontend callers can discover the field via TS completion.
W4: unify canvas height constant to 563 (matching the actual rendered
pixel count from capture.ts). The geometry detector's
CANVAS_HEIGHT, the snippet's Dimensions / coordinate system /
layout-guide text, and examples all now agree.
N6: add tests/orchestration/whiteboard-conflicts.test.ts — 17 cases
covering empty input, bbox overlap threshold (30%, 50%, 100%, plus
10% sub-threshold), line-crosses-bbox (through, endpoint-inside,
path-above), canvas clipping on all 4 edges, exact-edge placement
(not reported), malformed elements (skipped not crashed), and
output format.
All 43 tests pass; tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -120,12 +120,7 @@ export async function POST(req: NextRequest) {
|
||||
// Default: thinking disabled for low-latency chat. Callers (e.g. eval
|
||||
// harness) can opt in per-request by sending `thinking: { enabled: true }`
|
||||
// in the body.
|
||||
const requestedThinking = (body as unknown as { thinking?: ThinkingConfig })
|
||||
.thinking;
|
||||
const thinkingConfig: ThinkingConfig =
|
||||
requestedThinking && typeof requestedThinking === 'object'
|
||||
? requestedThinking
|
||||
: { enabled: false };
|
||||
const thinkingConfig: ThinkingConfig = body.thinking ?? { enabled: false };
|
||||
|
||||
const generator = statelessGenerate(
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
const CANVAS_WIDTH = 1000;
|
||||
const CANVAS_HEIGHT = 562;
|
||||
const CANVAS_HEIGHT = 563;
|
||||
const OVERLAP_THRESHOLD = 0.3; // intersection / min-area; flag if >= 30%
|
||||
|
||||
interface BBox {
|
||||
@@ -172,7 +172,7 @@ function shortId(id: string): string {
|
||||
* Detected conflicts:
|
||||
* - bbox overlap >= 30% of the smaller element's area
|
||||
* - line/arrow path crossing through any non-line element's bbox
|
||||
* - any element extending past the 1000×562 canvas bounds
|
||||
* - any element extending past the 1000×563 canvas bounds
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- PPTElement variants
|
||||
export function buildWhiteboardConflicts(elements: any[]): string {
|
||||
@@ -236,7 +236,7 @@ export function buildWhiteboardConflicts(elements: any[]): string {
|
||||
|
||||
const lines_out = conflicts.map((c) => ` - ${c}`).join('\n');
|
||||
return `\n## ⚠ Layout Conflicts Detected (computed from current whiteboard JSON)
|
||||
The following geometric conflicts exist on the board RIGHT NOW. Each one corresponds to a real visible problem in the prior-state image. You MUST address these before adding new content — either wb_delete one of the conflicting elements, or wb_clear and start fresh:
|
||||
The following geometric conflicts exist on the board RIGHT NOW. Each entry is a real visible problem on the current board. You MUST address these before adding new content — either wb_delete one of the conflicting elements, or wb_clear and start fresh:
|
||||
${lines_out}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
### Canvas Specifications
|
||||
|
||||
**Dimensions**: 1000 × 562 pixels.
|
||||
**Dimensions**: 1000 × 563 pixels.
|
||||
|
||||
**Coordinate system**: `x = 0` at the left edge, `x = 1000` at the right edge. `y = 0` at the top, `y = 562` at the bottom. Every element has `(left, top)` at its top-left corner.
|
||||
**Coordinate system**: `x = 0` at the left edge, `x = 1000` at the right edge. `y = 0` at the top, `y = 563` at the bottom. Every element has `(left, top)` at its top-left corner.
|
||||
|
||||
**Safe zone**: keep content within `x ∈ [20, 980]` and `y ∈ [20, 542]` to leave a 20px margin from the canvas edges.
|
||||
**Safe zone**: keep content within `x ∈ [20, 980]` and `y ∈ [20, 543]` to leave a 20px margin from the canvas edges.
|
||||
|
||||
**Reference points**:
|
||||
- Centered horizontally: `x = (1000 - width) / 2`
|
||||
- Centered vertically: `y = (562 - height) / 2`
|
||||
- Centered vertically: `y = (563 - height) / 2`
|
||||
- Two-column layout: left column `x ∈ [20, 480]`, right column `x ∈ [520, 980]` (40px gutter)
|
||||
|
||||
### JSON Output Context
|
||||
@@ -129,7 +129,7 @@ Render a data chart.
|
||||
| `themeColors` | string[] | no | Palette override. |
|
||||
| `elementId` | string | no | Stable ID. |
|
||||
|
||||
**Common mistake**: placing a chart that extends past `x + width = 1000` or `y + height = 562` — charts silently clip at canvas edges.
|
||||
**Common mistake**: placing a chart that extends past `x + width = 1000` or `y + height = 563` — charts silently clip at canvas edges.
|
||||
|
||||
#### wb_draw_table
|
||||
|
||||
@@ -268,11 +268,11 @@ The JSON parser sees `\f` (form feed), `\p` (kept as `\p`), `\s` (kept as `\s`).
|
||||
|
||||
### Bounds & Overlap
|
||||
|
||||
The canvas is **1000 × 562**. Elements that extend past the edges are clipped.
|
||||
The canvas is **1000 × 563**. Elements that extend past the edges are clipped.
|
||||
|
||||
**Hard bounds** (every element):
|
||||
- `x ≥ 0` and `x + width ≤ 1000`
|
||||
- `y ≥ 0` and `y + height ≤ 562`
|
||||
- `y ≥ 0` and `y + height ≤ 563`
|
||||
|
||||
**Safe zone** (preferred): `20 ≤ x`, `x + width ≤ 980`, `20 ≤ y`, `y + height ≤ 542`.
|
||||
|
||||
@@ -297,7 +297,7 @@ The canvas is **1000 × 562**. Elements that extend past the edges are clipped.
|
||||
chart occupies x=100..600, y=80..280
|
||||
next safe y = 80 + 200 + 30 = 310
|
||||
formula at (100, 310, height 80) → occupies y=310..390
|
||||
check: y + height = 390 ≤ 562 ✓
|
||||
check: y + height = 390 ≤ 563 ✓
|
||||
check: no overlap with chart (chart ends at y=280, formula starts at y=310) ✓
|
||||
```
|
||||
|
||||
@@ -350,12 +350,12 @@ Width is auto-computed from `height × aspect_ratio`; `width` acts as a horizont
|
||||
Before emitting whiteboard actions, mentally walk through these:
|
||||
|
||||
1. **[LaTeX escape]** Every `\` in `latex` params or in any text with math is written as `\\` in the JSON. Scan for single-backslash `\frac`, `\text`, `\theta`, `\times`, `\rightarrow`, `\circ`, `\beta`, `\varphi` — none should appear.
|
||||
2. **[Hard bounds]** For each element: `x ≥ 0`, `y ≥ 0`, `x + width ≤ 1000`, `y + height ≤ 562`.
|
||||
2. **[Hard bounds]** For each element: `x ≥ 0`, `y ≥ 0`, `x + width ≤ 1000`, `y + height ≤ 563`.
|
||||
3. **[Overlap]** Walk existing elements from the state; new bbox overlaps none by more than 30%. If tight, `wb_delete` first.
|
||||
4. **[Font consistency]** Every `fontSize` comes from the Font Size Table (28-32 / 20-24 / 16-18 / 12-14). No 8, 11, 48, 64.
|
||||
5. **[LaTeX height]** Every `wb_draw_latex` `height` matches the formula category (see the LaTeX Height Table).
|
||||
6. **[Redraw guard]** The element is not already on the whiteboard — if the state lists a formula/chart/table matching your intent, reference it instead of redrawing.
|
||||
7. **[Element type]** Math expressions use `wb_draw_latex`. Plain text uses `wb_draw_text`. Never embed LaTeX commands in text.
|
||||
8. **[Safe zone]** Where possible, stay within `x ∈ [20, 980]`, `y ∈ [20, 542]`.
|
||||
8. **[Safe zone]** Where possible, stay within `x ∈ [20, 980]`, `y ∈ [20, 543]`.
|
||||
9. **[Leave whiteboard open]** Do not call `wb_close` at the end of a drawing turn. Students need to read.
|
||||
10. **[Visual weight pairing]** Text that sits next to a LaTeX formula uses a `fontSize` matched to the LaTeX `height` per the pairing table above. No tiny 12-14px text next to height-80 formulas.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { UIMessage } from 'ai';
|
||||
import type { ThinkingConfig } from './provider';
|
||||
|
||||
// Session Types
|
||||
export type SessionType = 'qa' | 'discussion' | 'lecture';
|
||||
@@ -280,6 +281,12 @@ export interface StatelessChatRequest {
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
providerType?: string;
|
||||
/**
|
||||
* Opt-in: enable provider-side thinking for this request. Default is
|
||||
* `{ enabled: false }` (low-latency chat). Eval harness sets this to
|
||||
* `{ enabled: true }` when `EVAL_ENABLE_THINKING=1`.
|
||||
*/
|
||||
thinking?: ThinkingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildWhiteboardConflicts } from '@/lib/orchestration/summarizers/whiteboard-conflicts';
|
||||
|
||||
// Minimal PPTElement stand-ins — the summarizer only reads geometry fields.
|
||||
const text = (id: string, left: number, top: number, width: number, height: number) => ({
|
||||
type: 'text',
|
||||
id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
content: '<p>sample</p>',
|
||||
});
|
||||
|
||||
const table = (id: string, left: number, top: number, width: number, height: number) => ({
|
||||
type: 'table',
|
||||
id,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
data: [[{ text: 'a' }]],
|
||||
});
|
||||
|
||||
const line = (
|
||||
id: string,
|
||||
left: number,
|
||||
top: number,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
) => ({ type: 'line', id, left, top, start, end });
|
||||
|
||||
describe('buildWhiteboardConflicts — no conflicts', () => {
|
||||
test('empty element list returns empty string', () => {
|
||||
expect(buildWhiteboardConflicts([])).toBe('');
|
||||
});
|
||||
|
||||
test('two well-separated elements return empty string', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 20, 20, 200, 60),
|
||||
text('t2', 400, 200, 200, 60),
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('just-touching bboxes (intersection area = 0) are not reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 0, 0, 100, 100),
|
||||
text('t2', 100, 0, 100, 100), // shares only the x=100 edge
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('line routed clear of all elements produces no conflict', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [50, 400]),
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — bbox overlap', () => {
|
||||
test('one element fully inside another reports ~100% overlap', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
table('big', 0, 0, 500, 400),
|
||||
text('small', 50, 50, 100, 80), // entirely inside the table
|
||||
]);
|
||||
expect(out).toContain('OVERLAP:');
|
||||
expect(out).toContain('100%');
|
||||
});
|
||||
|
||||
test('50% overlap is reported; 10% is not (30% threshold)', () => {
|
||||
// Each bbox 100×100; smaller area = 10000. Overlap area = 50×100 = 5000 → 50%.
|
||||
const overlapping = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 50, 0, 100, 100),
|
||||
]);
|
||||
expect(overlapping).toContain('OVERLAP:');
|
||||
expect(overlapping).toContain('50%');
|
||||
|
||||
// Overlap area = 10×100 = 1000 → 10% — below threshold.
|
||||
const tiny = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 90, 0, 100, 100),
|
||||
]);
|
||||
expect(tiny).toBe('');
|
||||
});
|
||||
|
||||
test('non-line elements without width/height are skipped, not crashed', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 0, 0, 100, 100),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ type: 'text', id: 'broken', left: 10, top: 10 } as any, // missing width/height
|
||||
]);
|
||||
// Only one valid element remaining → no overlap to report.
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — line crossing elements', () => {
|
||||
test('line passing through the middle of a text box is reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60), // covers x∈[100,300], y∈[100,160]
|
||||
line('l1', 0, 0, [0, 130], [400, 130]), // horizontal line through y=130, cuts the box
|
||||
]);
|
||||
expect(out).toContain('LINE CROSSES:');
|
||||
expect(out).toContain('t1');
|
||||
});
|
||||
|
||||
test('line whose endpoint is inside a bbox is reported', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [200, 130]), // endpoint (200,130) is inside t1
|
||||
]);
|
||||
expect(out).toContain('LINE CROSSES:');
|
||||
});
|
||||
|
||||
test('line with endpoints on opposite sides of a box but path above the box is clean', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('t1', 100, 100, 200, 60),
|
||||
line('l1', 0, 0, [50, 50], [400, 50]), // y=50, above the box (y∈[100,160])
|
||||
]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — canvas edge clipping', () => {
|
||||
test('element extending past right edge is reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('wide', 900, 100, 200, 60)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('right edge by 100px');
|
||||
});
|
||||
|
||||
test('element extending past bottom edge is reported (canvas height = 563)', () => {
|
||||
const out = buildWhiteboardConflicts([text('tall', 100, 500, 100, 80)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('bottom edge by 17px'); // 500+80-563 = 17
|
||||
});
|
||||
|
||||
test('element with negative left is reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('negx', -10, 100, 50, 50)]);
|
||||
expect(out).toContain('OUT OF CANVAS:');
|
||||
expect(out).toContain('left edge by 10px');
|
||||
});
|
||||
|
||||
test('element exactly at right edge (x+w == 1000) is NOT reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('edge', 900, 100, 100, 60)]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
test('element exactly at bottom edge (y+h == 563) is NOT reported', () => {
|
||||
const out = buildWhiteboardConflicts([text('edge', 100, 500, 100, 63)]);
|
||||
expect(out).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWhiteboardConflicts — output format', () => {
|
||||
test('renders a single markdown block with a header and bullet list', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 50, 0, 100, 100),
|
||||
]);
|
||||
expect(out).toMatch(/## ⚠ Layout Conflicts Detected/);
|
||||
expect(out).toMatch(/\n {2}- OVERLAP:/);
|
||||
});
|
||||
|
||||
test('lists multiple conflicts in one block', () => {
|
||||
const out = buildWhiteboardConflicts([
|
||||
text('a', 0, 0, 100, 100),
|
||||
text('b', 50, 0, 100, 100), // overlap with a
|
||||
text('outside', 950, 100, 200, 60), // out of canvas
|
||||
]);
|
||||
const bullets = out.split('\n').filter((l) => l.trim().startsWith('- '));
|
||||
expect(bullets.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user