W4.1+W4.2: anti-AI-tics house style + bureau nav (back/home everywhere)
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 31s
CI / Scripts — Python smoke (push) Failing after 4s
CI / Web — npm audit (push) Failing after 31s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 4s

Two complaints in one wave:

(W4.1) User: "Não pode ter vícios de IA como uso excessivo de '-' que a IA
coloca geralmente no lugar de vírgulas por exemplo. Isso deve fazer parte
do prompt geral."

  - New prompts/_house-style.md banning the 9 most common AI prose tells
    in both EN and PT-BR:
      1. Em dashes as comma replacements (—)
      2. Rule-of-three lists ("concrete, rigorous, and grounded")
      3. Conjunctive openers ("Moreover", "Notably", "Ademais")
      4. Superficial -ing analyses ("marking a shift", "destacando")
      5. Inflated symbolism + AI vocab (tapestry, navigate, delve,
         underscore, robust, multifaceted, marco histórico, ...)
      6. Negative parallelisms ("Not just X but Y")
      7. Vague attribution ("Some scholars say...")
      8. Summary closers ("In summary...", "Em suma...")
      9. Hedging fluff ("It's important to note...")
    Verbatim chunk quotes are explicitly exempt; preserve as-is.
  - claude.ts callClaude() lazily loads _house-style.md once per process
    and PREPENDS it to every detective's system prompt:
        composedSystem = houseStyle + "---" + detective.systemPrompt
    This means all 7 detectives + future ones get the rules without any
    per-prompt change.

(W4.2) User: "Quando entra em uma página da investigação não tem como
voltar! UX terrível!"

  - New <BureauNav> sticky topbar with explicit "← home" + "🔎 bureau"
    buttons + clickable breadcrumb trail. Always visible at the top of
    every bureau page so the user can escape in one click.
  - Wired into /bureau, /h/[hypothesisId], /c/[slug], /jobs/[id]. Each
    page passes its sensible parent crumb (/bureau#hypotheses,
    /bureau#reports, /bureau#jobs).
  - Replaces the previous plain-text "disclosure.top / hypothesis /
    H-0004" line which had no visual affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-24 13:27:58 -03:00
parent 0a5c03c29a
commit 24f12a27f4
7 changed files with 232 additions and 32 deletions

View file

@ -0,0 +1,117 @@
# House style — bureau voice (mandatory)
This preamble is injected before every detective's system prompt. It applies
to **all narrative prose you emit, in both EN and PT-BR**. Verbatim chunk
quotes from the source corpus are exempt — preserve those as-is.
Your output will be read by an editor who will reject anything that smells
of AI-generated prose. The rules below are the editor's red pen.
## 1. NO em dashes as comma replacements
Forbidden: "...sobre o Novo México — território que abrigava os programas..."
Forbidden: "He was the director — a man of severe temperament — who..."
Use a comma. Or end the sentence and start a new one. If you genuinely need
parentheses, use parentheses. Em dashes are allowed **only** for true range
notation (e.g. `19481949`) and for verbatim quotes from the corpus that
already contain them.
In practice: if you can replace the dash with a comma without changing the
meaning, you should have used the comma.
## 2. NO rule-of-three lists
Forbidden: "concrete, quantitative, and grounded"
Forbidden: "rigorous, methodical, and exhaustive"
Forbidden: "uma análise cuidadosa, metódica e exaustiva"
Two adjectives is enough. Four is fine when you have four real items. The
default three is filler — drop the weakest term.
## 3. NO conjunctive fluff at sentence starts
Forbidden openers: "Moreover", "Furthermore", "Notably", "Importantly",
"It is worth noting", "It should be mentioned", "Crucially", "Indeed",
"Ademais", "Além disso", "Vale destacar", "É importante notar",
"Notadamente", "Cumpre observar".
Start the sentence with the content. If the link is real, use plain "But",
"And", "However", "So" — sparingly.
## 4. NO superficial -ing analyses
Forbidden: "marking a shift in policy", "highlighting the agency's concern",
"reflecting a deeper anxiety", "underscoring the importance",
"demonstrating the scale", "marcando uma mudança", "destacando a
preocupação", "refletindo uma ansiedade", "sublinhando a importância".
State the conclusion as a finite verb. "The agency changed its policy."
Not "the document, marking a shift in policy, ...". The -ing tail is
almost always filler that hedges the claim into mush.
## 5. NO inflated symbolism / promotional adjectives
Forbidden: "stands as a testament", "a beacon of", "speaks volumes",
"watershed moment", "innovative", "groundbreaking", "remarkable",
"unprecedented" (unless the corpus literally uses it), "robust",
"comprehensive", "multifaceted", "nuanced", "rich tapestry", "complex
landscape", "navigate the complexities", "leverage", "delve into",
"shed light on", "paints a picture", "extensive", "myriad".
PT-BR equivalents also forbidden: "marco histórico", "verdadeira riqueza",
"complexa tapeçaria", "panorama complexo", "navegar pelas complexidades",
"lança luz sobre", "demonstra de forma robusta", "abrangente",
"multifacetado", "pinta um quadro".
Show, don't characterize. If a result is remarkable, the reader will see
that from the evidence — you don't need the adjective.
## 6. NO negative parallelisms
Forbidden: "Not just X, but Y." / "It's not X, it's Y."
Forbidden: "Não apenas X, mas Y." / "Não se trata de X, mas de Y."
Just say Y. The negation of X is rhetorical scaffolding the editor will
delete.
## 7. NO vague attribution
Forbidden: "Some scholars argue...", "Many believe...", "It is widely
held...", "Críticos argumentam...", "Muitos sustentam...".
Cite the chunk: `[[doc-id/pNNN#cNNNN]]`. If no chunk supports the
attribution, you don't get to make the claim.
## 8. NO summary closers
Forbidden last sentences: "In summary...", "In conclusion...",
"Ultimately...", "Em suma...", "Em última análise...", "Em conclusão...",
"Resumindo...".
End on the last substantive sentence. The reader doesn't need to be told
the section is ending.
## 9. NO hedging fluff (separate from calibrated confidence_band)
Forbidden: "It's important to note that...", "It bears mentioning...",
"Of course...", "Naturally...", "Cabe ressaltar que...", "Naturalmente...",
"É claro que...".
The hedging you ARE allowed: posterior probability + Tetlock band, and
explicit `[no evidence in corpus]` markers. Anything else hedge-shaped is
filler.
---
## Quick self-check before emitting
Read your draft and ask:
- Did I use any em dash that could be a comma? Replace it.
- Did I write any list of exactly three items? Drop the weakest one.
- Did I start any sentence with "Moreover/Notably/Furthermore"? Cut it.
- Did I use any word from the forbidden list? Find a plain alternative.
- Did I write "in summary" / "em suma"? Delete that sentence.
The bureau's voice is **plainspoken investigative**. Like a senior detective
reporting facts to a colleague, not like a Wikipedia introduction.

View file

@ -7,8 +7,28 @@
* so the orchestrator can enforce the per-job budget cap.
*/
import { spawn } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { env } from "./env";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const HOUSE_STYLE_PATH = path.resolve(HERE, "..", "..", "prompts", "_house-style.md");
// Lazy-load the house style once per process. Detective system prompts get
// this preamble prepended automatically (the bureau's anti-AI-tics rules:
// no em dash as comma, no rule-of-three, no "Moreover", no inflated vocab).
let _houseStyleCache: string | null | undefined;
async function loadHouseStyle(): Promise<string | null> {
if (_houseStyleCache !== undefined) return _houseStyleCache;
try {
_houseStyleCache = await readFile(HOUSE_STYLE_PATH, "utf-8");
} catch {
_houseStyleCache = null;
}
return _houseStyleCache;
}
export interface ClaudeCallArgs {
/** Free-form prompt body to send. */
prompt: string;
@ -69,8 +89,15 @@ export async function callClaude(args: ClaudeCallArgs): Promise<ClaudeCallResult
// long multi-line content reliably (the CLI complained "Input must be
// provided either through stdin or as a prompt argument when using --print"
// for prompts past a few KB). Stdin is unambiguous.
const fullPrompt = args.systemPrompt
? `${args.systemPrompt}\n\n---\n\n${args.prompt}`
// Compose system prompt: house style (anti-AI-tics) + detective persona.
// House style goes FIRST so the model treats it as a hard constraint that
// wraps the detective's discipline.
const houseStyle = await loadHouseStyle();
const composedSystem = [houseStyle, args.systemPrompt]
.filter((s): s is string => typeof s === "string" && s.trim().length > 0)
.join("\n\n---\n\n");
const fullPrompt = composedSystem
? `${composedSystem}\n\n---\n\n${args.prompt}`
: args.prompt;
const t0 = Date.now();

View file

@ -8,6 +8,7 @@
import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -141,13 +142,9 @@ export default async function BureauPage() {
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<BureauNav crumbs={[{ label: "bureau" }]} />
<AuthBar />
<div className="mx-auto max-w-6xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span className="text-[#e0c080]">bureau</span>
</div>
<div className="mx-auto max-w-6xl px-4 py-6 pt-4">
<header className="mb-8 border-b border-[rgba(224,192,128,0.32)] pb-4">
<h1 className="font-mono text-3xl text-[#e0c080]"> The Investigation Bureau</h1>

View file

@ -11,6 +11,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { MarkdownBody } from "@/components/markdown-body";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -74,15 +75,13 @@ export default async function CaseReportPage({
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<BureauNav crumbs={[
{ label: "bureau", href: "/bureau" },
{ label: "reports", href: "/bureau#reports" },
{ label: slug },
]} />
<AuthBar />
<div className="mx-auto max-w-3xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span>case-report</span>
<span className="mx-1">/</span>
<span className="text-[#e0c080]">{slug}</span>
</div>
<div className="mx-auto max-w-3xl px-4 py-6 pt-4">
<div className="rounded-lg border border-[rgba(224,192,128,0.18)] bg-gradient-to-br from-[rgba(224,192,128,0.06)] to-transparent p-4 mb-6">
<div className="text-[10px] font-mono text-[#5a6678] uppercase mb-2">

View file

@ -17,6 +17,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { RedTeamRequestButton } from "@/components/red-team-request-button";
export const runtime = "nodejs";
@ -196,15 +197,13 @@ export default async function HypothesisPage({
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<BureauNav crumbs={[
{ label: "bureau", href: "/bureau" },
{ label: "hypotheses", href: "/bureau#hypotheses" },
{ label: hypothesisId },
]} />
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span>hypothesis</span>
<span className="mx-1">/</span>
<span className="text-[#7fdbff]">{hypothesisId}</span>
</div>
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
{/* Header */}
<div className="rounded-lg border border-[rgba(127,219,255,0.18)] bg-gradient-to-br from-[rgba(127,219,255,0.08)] to-transparent p-5">

View file

@ -18,6 +18,7 @@ import { notFound } from "next/navigation";
import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { JobStatusPoller } from "@/components/job-status-poller";
export const runtime = "nodejs";
@ -109,15 +110,13 @@ export default async function JobPage({
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<BureauNav crumbs={[
{ label: "bureau", href: "/bureau" },
{ label: "jobs", href: "/bureau#jobs" },
{ label: job.job_id.slice(0, 8) },
]} />
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-8 pt-16">
<div className="text-[11px] text-[#5a6678] font-mono mb-2">
<Link href="/" className="hover:text-[#7fdbff]">disclosure.top</Link>
<span className="mx-1">/</span>
<span>investigation</span>
<span className="mx-1">/</span>
<span className="text-[#7fdbff]">{job.job_id.slice(0, 8)}</span>
</div>
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
<div className={`rounded-lg border border-[rgba(127,219,255,0.18)] bg-gradient-to-br ${detectiveBg} to-transparent p-5`}>
<div className="flex items-baseline justify-between gap-4 flex-wrap">

View file

@ -0,0 +1,62 @@
/**
* BureauNav top navigation bar present on every bureau page.
*
* Solves "I'm stuck on this page, there's no way back" UX. Shows:
* - "← Home" (always /)
* - "🔎 Bureau" (always /bureau)
* - Breadcrumb trail of current page (passed via props)
*
* Server component. No client-side history needed because the parent links
* are deterministic (every bureau page has a known parent in the hierarchy).
*/
import Link from "next/link";
import { ArrowLeft, Home } from "lucide-react";
export interface Crumb {
/** Display label. */
label: string;
/** When omitted the crumb is rendered as the current page (no link). */
href?: string;
}
export function BureauNav({ crumbs }: { crumbs: Crumb[] }) {
return (
<nav className="sticky top-0 z-30 bg-[#0a0e1a]/95 backdrop-blur border-b border-[rgba(224,192,128,0.18)]">
<div className="mx-auto max-w-6xl px-4 py-2 flex items-center gap-3 flex-wrap text-[12px] font-mono">
<Link
href="/"
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-[rgba(127,219,255,0.30)] text-[#7fdbff] hover:bg-[rgba(127,219,255,0.08)]"
aria-label="Voltar para a home"
>
<ArrowLeft size={12} />
<Home size={12} />
<span>home</span>
</Link>
<Link
href="/bureau"
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-[#e0c080] text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)]"
aria-label="Hub do bureau"
>
🔎 <span>bureau</span>
</Link>
{crumbs.length > 0 && (
<div className="text-[11px] text-[#5a6678] flex items-center gap-1.5 flex-wrap">
<span className="mx-1">/</span>
{crumbs.map((c, i) => (
<span key={`${c.label}-${i}`} className="inline-flex items-center gap-1.5">
{c.href ? (
<Link href={c.href} className="hover:text-[#7fdbff]">{c.label}</Link>
) : (
<span className="text-[#e7ecf3]">{c.label}</span>
)}
{i < crumbs.length - 1 && <span>/</span>}
</span>
))}
</div>
)}
</div>
</nav>
);
}