TypeScript · Open Council

Every Chinese Keyword Was Dead Code: Routing, Roles, and a Regex Trap

An Open Council postmortem: how to decide how many rounds a question needs, who to send in, and what role each model plays. Starting from a classic regex trap where `\b` killed every Chinese routing keyword.

When Open Council receives a question, it first decides three things: how many rounds to debate (quick or debate), how many models to send in, and what role each plays. This routing runs entirely on keyword heuristics, at zero LLM cost. But it hid a classic regex trap — every Chinese routing keyword was once completely dead code.

\b can’t see Chinese

Routing’s first step is classifying the question: code / architecture / security / comparison / math / general. The basis is keyword tables — architecture matches “architecture, design, microservice, scalable,” security matches “injection, auth, vulnerability.”

The original matcher wrapped each keyword in \b...\b (word boundaries) — the standard English idiom, to keep code from matching a substring of encoder. The problem: \b only recognizes ASCII word characters. Between a Chinese character and its neighbor, a \b boundary never forms. So any Chinese keyword wrapped in \b never matches. The entire Chinese routing table might as well not exist — until someone tested with a Chinese question.

The fix was splitting keywords into two groups by language, assembled into one regex with different rules:

\b(?:en_keywords)\b | (?:zh_keywords)

The English group keeps \b (or code again hits encoder); the Chinese group matches bare (literal, no boundary). This is the root reason the keyword table must split en / zh — not for looks, but because “word boundary” simply isn’t the same thing across the two languages.

One Chinese character counts as two and a half

The same trap has a second layer. Judging question complexity looks at length: short questions go to compare, long ones (architecture past a certain length) escalate to debate. But the length thresholds were tuned for English, and applied to Chinese they badly underestimate — Chinese is information-dense, carrying far more content per character.

So length doesn’t use raw character count; it uses a weighted effectiveLength: each CJK character counts as 2.5, everything else as 1. Along with it, the length gate for architecture/security is pressed very low — a 17-character Chinese architecture question (weighted to about 43) must be able to reach debate. The keywords already confirmed the question has substance; the length gate just filters out one-liners like “is architecture good?”, so the bar is set low.

Who goes in: prefer ordering + seat constraints

Once classified, candidate models get ordered. Ordering is driven by prefer in the config (an ordered preference list):

  • Both models in prefer → ordered by their listed positions;
  • Only one in it → it goes first;
  • Neither in it → by capability tier descending, then priority ascending (smaller number = higher priority).

prefer has a drift trap: the models are still there, but the prefer list didn’t get updated (e.g., a rescan found new models but didn’t add them to prefer). The countermeasure is deduping prefer at every entry point that constructs it, and appending new models on rescan rather than dropping them.

Seat count is an intersection of mode and config: quick fixes 1 seat, compare at least 2, debate at least 3, with the cap being the smaller of “model count” and “config max.” When too few models make min > max, min gets clamped to max and a degradation event is recorded — having too few models is a normal reality; rather than error out, clamp and honestly tell the user “degraded.”

Assigning roles: let a cheap model design the debate

Roles (analyst, engineer, innovator…) aren’t hardcoded; by default a “let an LLM design the panel” path runs: one model reads the profiles of all participating models, then designs what roles this debate should have and which model fills each.

A counterintuitive trade-off: the model designing the panel is deliberately not the strongest one — it prefers a mid (balanced) tier. The reason: designing a panel doesn’t need top-tier intelligence — smart enough to reason out “put reasoning-heavy roles on strong models, speed roles on fast models” is enough, and the mid tier is cheaper and faster. Save the top-tier model for the real work.

Role diversity is enforced by hard constraints in the prompt: “each role must have a unique and contrasting perspective — they should disagree on key points,” “create productive tension, not redundant agreement,” “spread roles across different models/providers when possible.” The seat count is also left to the LLM within the range, with an explicit ask to “pick the smallest count that still produces productive disagreement, don’t pad with redundant roles to fill the maximum.” When the LLM fails or there are no available models, it falls back to a hardcoded set of roles (🔍 analyst, ⚙️ engineer, 💡 innovator, 🎯 critic, 📐 pragmatist) assigned round-robin.

Another boundary trap: gpt-5 shouldn’t swallow gpt-5-nano

Once the LLM designs the roles, it fills in “which model this role goes to” in JSON. The name it gives may be imprecise, so it has to be resolved back to a real model.

The early implementation used a naive bidirectional includes, and gpt-5 would silently land on gpt-5-nano, and conversely gpt-5 would swallow gpt-50 — one role assignment quietly sending the wrong model. The fix is a boundary-safe prefix match: the prefix must be followed by -, ., or a letter-to-digit transition (gptgpt4), with consecutive digits treated as one number (gpt-5 won’t swallow gpt-50); with multiple candidates, take the shortest id (the least-specialized family member). This rule set was extracted into a shared module so routing and API invocation share the same boundary logic, instead of each writing its own copy that slowly drifts.

A small honest note in passing: resolveMode’s comment says the thresholds are 50 / 120 / 30, but the actual architecture/security gate uses 40 — the comment didn’t keep up with a code change. In “a pile of magic numbers” places like routing, comment drift is nearly inevitable, and worth reconciling periodically.

Takeaways

From a question to “who plays what role,” a few decisions and traps along the routing chain:

  • \b can’t see Chinese: word boundaries only recognize ASCII, Chinese keywords need bare matching, en / zh must split;
  • Chinese weighted 2.5×: length thresholds tuned for English need effectiveLength weighting for Chinese, or you systematically underestimate complexity;
  • prefer drifts: models present but the preference list out of sync — dedupe everywhere + backfill on rescan;
  • Let a cheap model design the debate: use a mid tier, not top tier, for panel design — smart enough and cheaper, save the top tier for real work;
  • Model-name matching must be boundary-safe: gpt-5 can’t swallow gpt-5-nano, extract the rule for two callers to share and prevent drift.

In one line: cross-language, cross-model string matching hides almost all its traps at the “boundaries” — English’s word boundary, Chinese’s information density, a model name’s version boundary; every one you take for granted becomes a silent error.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.