Skip to content

Commit 05ae41d

Browse files
authored
style: tighten comments and drop stale scratch tooling (#58)
* style: tighten comments and drop stale scratch tooling * style: note tab tolerance in fence-boundary comment
1 parent fc7a30f commit 05ae41d

32 files changed

Lines changed: 123 additions & 689 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ service-account*.json
4646
# Superpowers brainstorm sessions
4747
.superpowers/
4848

49-
# Playwright MCP (Claude Code)
49+
# Playwright artifacts
5050
.playwright-mcp/
5151

5252
# Test screenshots

package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
"lint": "eslint .",
1111
"format": "prettier --write .",
1212
"format:check": "prettier --check .",
13-
"test": "vitest run",
14-
"test:providers": "npx tsx src/lib/providers/test-providers.ts"
13+
"test": "vitest run"
1514
},
1615
"dependencies": {
1716
"@anthropic-ai/sdk": "^0.80.0",

src/app/api/chat/route.ts

Lines changed: 14 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,7 @@ function getResponseLengthInstruction(length: ResponseLength): string {
3131
function getMaxTokens(length: ResponseLength): number {
3232
switch (length) {
3333
case "short":
34-
// Korean uses 2-3x more tokens than English in Gemini's tokenizer.
35-
// Bumped from 350 to 800 so short responses don't get cut mid-sentence
36-
// in Korean. Output is still clamped by clampToWordLimit downstream.
34+
// Korean tokenizes 2-3x heavier; 800 keeps short mode from cutting mid-sentence.
3735
return 800
3836
case "long":
3937
return 4096
@@ -72,16 +70,8 @@ function stripUnmatchedPair(text: string, token: string): string {
7270
return `${text.slice(0, lastIndex)}${text.slice(lastIndex + token.length)}`
7371
}
7472

75-
/**
76-
* Walks from the end of a truncated response and drops lines that are
77-
* dangling markdown structure with no body: a heading that has no content
78-
* below it, a lone bullet or numbered marker, or a table row that was cut
79-
* mid-write (starts with `|` but doesn't close with `|`). Only used on
80-
* responses that the stream clamper actually truncated, so normal answers
81-
* are never touched.
82-
*
83-
* Exported for unit testing.
84-
*/
73+
/** Drops dangling markdown at a truncation point: a heading with no body, a
74+
* lone list marker, a half-written table row. Only runs on clamped text. */
8575
export function stripDanglingStructure(text: string): string {
8676
const lines = text.split("\n")
8777
while (lines.length > 0) {
@@ -90,24 +80,17 @@ export function stripDanglingStructure(text: string): string {
9080
lines.pop()
9181
continue
9282
}
93-
// Any heading at the absolute end of the response is dangling by
94-
// definition - a properly-followed heading would not be the last
95-
// non-blank line. Applies to both `### Title` (heading cut before
96-
// its body) and `### ` (marker alone).
83+
// A heading as the last non-blank line is dangling by definition.
9784
if (/^#{1,6}(?:\s.*)?$/.test(last)) {
9885
lines.pop()
9986
continue
10087
}
101-
// Lone list marker with nothing after it: `-`, `* `, `1.`, `2. `, etc.
88+
// Lone list marker.
10289
if (/^(?:[-*]|\d+\.)\s*$/.test(last)) {
10390
lines.pop()
10491
continue
10592
}
106-
// Truncated table row: line starts with `|` and EITHER does not close
107-
// with `|` (mid-cell cut) OR has fewer pipes than the pipe-line above
108-
// it (cut between cells, still has a trailing `|` but not enough
109-
// columns). Complete rows that match the column count of the row
110-
// above are left alone.
93+
// Table row cut mid-cell (no closing pipe) or short of the row above's columns.
11194
if (/^\s*\|/.test(last)) {
11295
const endsWithPipe = /\|\s*$/.test(last)
11396
if (!endsWithPipe) {
@@ -131,15 +114,8 @@ export function stripDanglingStructure(text: string): string {
131114
return lines.join("\n").trimEnd()
132115
}
133116

134-
/**
135-
* Polishes a response that the word-limit clamper had to truncate mid-write.
136-
* Tries to end at the last complete sentence, strips dangling markdown
137-
* structure (headings/bullets/table rows with no body), removes unmatched
138-
* bold/italic/code markers, and appends an ellipsis if the result still
139-
* doesn't end cleanly. Only called when `wasTruncated` is true.
140-
*
141-
* Exported for unit testing.
142-
*/
117+
/** Ends clamped text cleanly: last full sentence, unmatched-marker strip,
118+
* dangling-structure drop, ellipsis fallback. Truncated responses only. */
143119
export function polishTruncatedResponse(text: string, wordLimit: number): string {
144120
let result = text.trimEnd()
145121

@@ -188,12 +164,7 @@ export function polishTruncatedResponse(text: string, wordLimit: number): string
188164
return clampToWordLimit(result, wordLimit).text
189165
}
190166

191-
// Formatting guidance varies by length. Short stays plain prose so tight
192-
// answers don't get cluttered with structure. Medium unlocks bullets and
193-
// numbered lists when the content is naturally a list. Long unlocks ###
194-
// and #### subheadings, GFM tables, and fenced code, but NOT # or ##
195-
// (those would compete with the app's own UI headers). The guiding line
196-
// in every tier: "structure is a tool, not decoration."
167+
// Formatting ladder: short = prose only, medium = lists, long = ###/tables/code. # and ## stay forbidden - they compete with the app's own UI headers.
197168
export function getFormattingInstruction(length: ResponseLength, isKorean: boolean): string {
198169
if (isKorean) {
199170
switch (length) {
@@ -309,11 +280,7 @@ export async function POST(request: Request) {
309280
}
310281

311282
const inputMessages = messages.filter((m) => m.sender !== "system" && m.sender !== "verdict")
312-
// CRITICAL: only consider the USER's own messages when detecting the
313-
// conversation language. Otherwise one AI that hallucinates a Korean
314-
// summary mid-response tips the ratio and forces every subsequent
315-
// provider in the debate to respond entirely in Korean - the
316-
// cascading Korean drift bug observed on 2026-04-11.
283+
// Detect language from USER messages only - one model hallucinating Korean would otherwise cascade the whole debate into Korean.
317284
const userOnlyMessages = inputMessages.filter((m) => m.sender === "user")
318285
const forceKorean = validatedLocale !== "ko" && isPredominantlyKorean(userOnlyMessages)
319286
const streamFn = getStreamFn(provider)
@@ -331,20 +298,12 @@ export async function POST(request: Request) {
331298
requestApiKey
332299
)
333300
if (blockedResponse) return blockedResponse
334-
// Hard word caps per response length. These sit behind the prompt
335-
// instruction as a belt-and-suspenders guard: when a provider (Gemini
336-
// especially) decides to run 2x longer than requested, the server
337-
// clamps the stream mid-flight instead of letting the oversized
338-
// bubble dominate the debate feed.
301+
// Hard server-side clamp behind the prompt instruction; Gemini especially overruns.
339302
const wordLimit =
340303
validatedResponseLength === "short" ? 75 : validatedResponseLength === "medium" ? 170 : 500
341304
const encoder = new TextEncoder()
342305
let fullContent = ""
343-
// Whether any clampToWordLimit pass actually truncated the stream.
344-
// We only call polishTruncatedResponse when this is true, because
345-
// polishing can strip unmatched markdown pairs and trailing
346-
// connector words, which would unintentionally mutate normal
347-
// (non-truncated) responses in medium/long mode.
306+
// Polishing mutates text (marker strip, connector trim) - only run it on actually-truncated responses.
348307
let wasTruncated = false
349308

350309
const stream = new ReadableStream({
@@ -417,11 +376,7 @@ export async function POST(request: Request) {
417376
fullContent = polishTruncatedResponse(fullContent, wordLimit)
418377
}
419378

420-
// Empty-stream guard: providers occasionally close the stream
421-
// without yielding any text (safety filter, transient model glitch).
422-
// streamGemini already retries once internally. Surface it as an
423-
// explicit empty flag so the client can show a clear fallback
424-
// instead of a placeholder stuck in "thinking..." forever.
379+
// Providers occasionally close without yielding (safety filter, glitch); flag it so the client shows a fallback instead of eternal "thinking...".
425380
const isEmpty = !fullContent.trim()
426381

427382
enqueueEvent({
@@ -438,16 +393,7 @@ export async function POST(request: Request) {
438393
closeController()
439394
return
440395
}
441-
// Route provider failures through the `error` channel rather
442-
// than streaming them as bubble content. The old path wrote
443-
// `${DISPLAY_NAMES[provider]} encountered an error: ${raw}`
444-
// into a chat chunk, so upstream garbage like
445-
// "[VertexAI.ClientError]: got status: 499 Client Closed
446-
// Request. {"error":{"code":499,..}}" leaked verbatim into
447-
// the user-visible bubble. On the client side, data.error
448-
// throws into callModel's catch, which now substitutes the
449-
// friendly "stepped out for a snack break" system message.
450-
// The raw detail still lands in the server log for debugging.
396+
// Provider failures go through the error channel, never bubble content - raw upstream errors used to leak verbatim into the chat. Client shows the friendly fallback; detail stays in the server log.
451397
const msg = error instanceof Error ? error.message : "Unknown error"
452398
const sanitized = redactSecrets(msg)
453399
console.error(`[chat/${provider}] stream failed:`, sanitized)

src/app/api/consensus/route.ts

Lines changed: 11 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,10 @@ import { generateGeminiVerdictWithApiKey, getConfiguredGeminiApiKey } from "@/li
1515
import { resolveUserProviderApiKey } from "@/lib/server-provider-keys"
1616
import { redactSecrets } from "@/lib/redact-secrets"
1717

18-
/**
19-
* Vertex AI response schema mirroring validateVerdictResult. Forces the
20-
* model to emit a structurally valid JSON object with our exact field
21-
* names, instead of "helpful" prose or invented schemas (gemini-2.5-flash
22-
* was observed returning markdown bullet lists and renaming
23-
* recommendedAnswer to summary/recommendation when given only natural-
24-
* language instructions).
25-
*
26-
* Vertex's Schema subset doesn't support min/max constraints on numbers,
27-
* so the runtime range check on `confidence` stays in
28-
* validateVerdictResult as defense in depth. Optional fields are listed
29-
* in `properties` but omitted from `required` so the model can skip them.
30-
*/
18+
/** Forces structurally valid JSON with our exact field names - flash was seen
19+
* emitting markdown lists and renaming fields under prose instructions alone.
20+
* Vertex's Schema subset lacks numeric min/max, so the confidence range check
21+
* stays in validateVerdictResult. */
3122
const VERDICT_RESPONSE_SCHEMA: ResponseSchema = {
3223
type: SchemaType.OBJECT,
3324
properties: {
@@ -147,12 +138,7 @@ export async function POST(req: NextRequest) {
147138
(m) => m.sender !== "system" && m.sender !== "verdict"
148139
)
149140

150-
// Match the chat route's behavior: if the USER'S OWN messages are
151-
// predominantly Korean, force the verdict prompt into Korean even
152-
// when the UI locale is English. We intentionally ignore AI responses
153-
// here so a single hallucinated Korean span in one AI's reply can't
154-
// cascade the whole verdict into Korean - same class of bug that
155-
// hit the chat route on 2026-04-11.
141+
// User messages only - AI hallucinating Korean must not cascade the whole verdict into Korean.
156142
const userOnlyMessages = discussionMessages.filter((m) => m.sender === "user")
157143
const effectiveLocale: Locale = isPredominantlyKorean(userOnlyMessages) ? "ko" : locale
158144

@@ -169,16 +155,7 @@ export async function POST(req: NextRequest) {
169155

170156
const thread = formatThread(discussionMessages)
171157

172-
// Hybrid Flash/Pro verdict routing. The verdict's job is synthesis,
173-
// not deep reasoning - the 4 panelist AIs already did the heavy
174-
// thinking, and Flash is more than capable of picking the consensus
175-
// and formatting it. The one case where Pro is meaningfully better
176-
// is continuations: when there's already a prior verdict in the
177-
// thread, the new verdict must reconcile against it without flip-
178-
// flopping, and Pro is noticeably steadier at that. Everything else
179-
// - any length, any round count, any model count - uses Flash, which
180-
// cuts verdict wall time from ~15s to ~5s and now produces
181-
// schema-conforming JSON thanks to responseSchema enforcement.
158+
// Flash for first verdicts (~5s); Pro for continuations where it must reconcile without flip-flopping.
182159
const useProModel = previousVerdicts.length > 0
183160
const verdictModelName = useProModel ? "gemini-2.5-pro" : "gemini-2.5-flash"
184161
const verdictTier = useProModel ? "pro" : "flash"
@@ -231,12 +208,7 @@ export async function POST(req: NextRequest) {
231208
`[verdict] Generating verdict tier=${verdictTier} source=${userGeminiApiKey ? "user-key" : geminiApiKey ? "gemini-api-key" : "vertex"} for ${aiMessages.length} AI messages, locale=${locale}, effective=${effectiveLocale}, responseLength=${responseLength}, previousVerdicts=${previousVerdicts.length}`
232209
)
233210

234-
// Per-tier timeout. Pro verdicts on a 2-round debate with 8 AI
235-
// messages legitimately run over a minute; give Pro a full 2
236-
// minutes of headroom so it never aborts mid-synthesis. Flash
237-
// verdicts finish well under 10s in local testing, so 30s is
238-
// plenty of margin - anything past that is a real problem we
239-
// want to surface quickly rather than hide behind a long wait.
211+
// Pro can legitimately take 2+ minutes on long debates; Flash is well under 10s so 30s surfaces real problems fast.
240212
const VERDICT_TIMEOUT_MS = useProModel ? 120_000 : 30_000
241213

242214
const generateVerdictText = async (
@@ -277,11 +249,6 @@ export async function POST(req: NextRequest) {
277249
parts: [{ text: userPrompt }],
278250
},
279251
],
280-
// Force structurally valid JSON matching VERDICT_RESPONSE_SCHEMA.
281-
// Without this, gemini-2.5-flash returns markdown prose or
282-
// invents alternate schemas (summary/recommendation instead of
283-
// recommendedAnswer). With it, both Flash and Pro emit
284-
// schema-conforming JSON the validator can accept directly.
285252
generationConfig: verdictGenerationConfig,
286253
}),
287254
new Promise<never>((_, reject) =>
@@ -306,22 +273,15 @@ export async function POST(req: NextRequest) {
306273
const raw = firstResult.raw
307274

308275
if (!raw) {
309-
// Empty responses are usually a safety filter trip or a Pro
310-
// thinking-only response with no output tokens. Log the full
311-
// candidate shape so we can see finishReason/safetyRatings when
312-
// this recurs.
276+
// Safety filter or Pro thinking-only; log shape for debugging.
313277
console.error(`[verdict] Empty response. finishReason=${firstResult.finishReason}`, {
314278
safetyRatings: firstResult.safetyRatings,
315279
promptFeedback: firstResult.promptFeedback,
316280
})
317281
throw new Error("Gemini returned an empty response")
318282
}
319283

320-
// Log ONLY metadata in production - the raw-response preview
321-
// echoes model output which frequently contains user document
322-
// content, legal text, or other sensitive inputs. In dev we keep
323-
// the preview for parse-debugging; in prod only length and the
324-
// Vertex finishReason ship to CloudWatch / equivalent.
284+
// Raw preview only in dev - it echoes user document content which must not land in prod logs.
325285
if (process.env.NODE_ENV === "development") {
326286
console.log(
327287
`[verdict] Raw response length=${raw.length}, finishReason=${firstResult.finishReason ?? "unknown"}, first200=${raw.slice(0, 200).replace(/\n/g, "\\n")}`
@@ -332,13 +292,7 @@ export async function POST(req: NextRequest) {
332292
)
333293
}
334294

335-
// Try to extract a JSON object from the response, even if Pro
336-
// wrapped it in thinking text or markdown prose. Strategy:
337-
// 1. Strip markdown code fences (```json ... ``` wrappers).
338-
// 2. If the result isn't pure JSON, find the first `{` and the
339-
// last `}` and slice between them - this tolerates a preamble
340-
// like "Here's my analysis:" or a trailing summary comment
341-
// without needing a repair round-trip.
295+
// Strip fences, then slice first-{ to last-} to tolerate Pro preamble/trailing text.
342296
const extractJson = (text: string): string => {
343297
const stripped = text
344298
.replace(/```json\s*/gi, "")
@@ -357,10 +311,7 @@ export async function POST(req: NextRequest) {
357311
try {
358312
parsed = JSON.parse(cleaned)
359313
} catch (jsonErr) {
360-
// First-pass parse failed. Ask Gemini to repair it, then extract
361-
// again. The cleaned/retry previews contain model output which
362-
// can echo user documents and legal text - gate them to dev so
363-
// they never land in production server logs.
314+
// Repair retry; previews gated to dev (echo user content).
364315
const parseErrMsg = jsonErr instanceof Error ? jsonErr.message : String(jsonErr)
365316
if (process.env.NODE_ENV === "development") {
366317
console.warn(
@@ -369,9 +320,6 @@ export async function POST(req: NextRequest) {
369320
} else {
370321
console.warn(`[verdict] JSON parse failed: ${parseErrMsg}`)
371322
}
372-
// Same schema enforcement on the repair retry. Without it the
373-
// retry path was the second observed source of wrong-schema
374-
// output (Flash inventing field names like 'summary').
375323
const retryResult = await generateVerdictText(
376324
`The following JSON is malformed. Fix it and return ONLY valid JSON, no other text:\n\n${cleaned}`,
377325
30_000

0 commit comments

Comments
 (0)