W5.5 (Phase 3C): Sun-Tzu strategist feeder + entity hero illustrations
Sun-Tzu (silent backend) — builds the strongest pro-anomaly brief the
corpus supports for any topic. Bilingual JSON: thesis + 2-4 pillars
(each with claim + citation-backed support) + honest residual
unexplained clause. NEVER surfaced reader-facing.
Migration 0009 (apply as supabase_admin):
public.pro_anomaly_briefs brief_pk BIGSERIAL PK
brief_id B-NNNN unique
topic + topic_pt_br
thesis + thesis_pt_br
pillars JSONB
unexplained + unexplained_pt_br
doc_id, job_id, created_by, created_at
+ brief_id_seq sequence
+ GIN trigram indexes on topic + topic_pt_br
+ RLS policies (investigator INSERT, public SELECT)
+ GRANTs on seq + table to investigator
prompts/sun-tzu.md
"Adversarial strategist who plays the pro-disclosure side with the
same rigour a red-team plays skeptic" — single thesis, 2-4 pillars,
honest residual. Every claim cites a chunk. No fabrication from
training-time knowledge. Output INTERNAL — case-writer pulls it.
Bilingual mandatory. NO_STRONG_CASE sentinel when corpus is thin.
detectives/sun_tzu.ts
Grounds with hybridSearch top 18 chunks, calls Sonnet, parses
JSON strict, calls writeProAnomalyBrief.
tools/write_pro_anomaly_brief.ts
Validates 2-4 pillars with bilingual claim+support, requires at
least one [[wiki-link]] citation per pillar, INSERTs.
orchestrator: new kind "anomaly_brief" dispatches Sun-Tzu.
Case-writer integration (detectives/case_writer.ts):
- Pulls most recent matching brief via ILIKE on topic or doc_id.
- Renders brief as a separate prompt section labelled
"Strategic brief (internal — do NOT cite or attribute)".
- Instructs the narrator to weave the thesis as a quiet through-
line, use pillar facts in scenes, let the unexplained clause
inform the closing paragraph. Forbidden to name "the analyst",
say "a brief argues", or use the words "thesis"/"pillar"
explicitly. Translate it into prose.
Entity hero illustrations:
- 3 painterly editorial illustrations generated via Nano Banana
Pro at 2K, stored under /data/disclosure/processing/case-art/:
* EV-1947-06-24-kenneth-arnold-sighting.png — cockpit POV of
Arnold in a CallAir A-2 over Mount Rainier, 9 chevron disc
objects in formation, 1947 Life-magazine register.
* EV-1947-07-08-roswell-incident.png — debris field in NM
desert, USAAF officer in 1947 uniform examining foil
fragments, period staff car.
* EV-1947-06-21-maury-island-incident.png — wooden patrol
boat on Puget Sound, 6 doughnut craft hovering, one
shedding glowing slag, Harold Dahl + son + dog watching.
- app/e/[cls]/[id]/page.tsx: full-bleed editorial hero replaces
the old gradient header card when an illustration exists for
that entity_id. Title sits over the painting with gradient
overlay. "Ilustração editorial" chip in the top-right.
Quota note: Claude OAuth still rate-limited as of this commit, so
Sun-Tzu hasn't been smoke-tested in production. Code is shipped and
ready; first brief will land when the weekly quota refreshes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8283237f87
commit
2ac42b99a7
8 changed files with 502 additions and 4 deletions
49
infra/supabase/migrations/0009_pro_anomaly_briefs.sql
Normal file
49
infra/supabase/migrations/0009_pro_anomaly_briefs.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
-- 0009_pro_anomaly_briefs.sql — Sun-Tzu's silent intermediate artefact.
|
||||||
|
--
|
||||||
|
-- The strategist produces one brief per topic. The case-writer pulls
|
||||||
|
-- the brief at narrative-assembly time and weaves the thesis + pillars
|
||||||
|
-- into a confident closing scene. The brief is NEVER surfaced reader-
|
||||||
|
-- facing — the table is internal to the runtime.
|
||||||
|
--
|
||||||
|
-- Apply as supabase_admin.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS public.brief_id_seq START 1;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.pro_anomaly_briefs (
|
||||||
|
brief_pk BIGSERIAL PRIMARY KEY,
|
||||||
|
brief_id TEXT UNIQUE NOT NULL, -- B-NNNN
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
topic_pt_br TEXT,
|
||||||
|
doc_id TEXT, -- optional scope
|
||||||
|
thesis TEXT NOT NULL,
|
||||||
|
thesis_pt_br TEXT NOT NULL,
|
||||||
|
pillars JSONB NOT NULL, -- [{claim,claim_pt_br,support,support_pt_br}]
|
||||||
|
unexplained TEXT NOT NULL,
|
||||||
|
unexplained_pt_br TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL DEFAULT 'strategist@detective',
|
||||||
|
job_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_topic_trgm
|
||||||
|
ON public.pro_anomaly_briefs USING gin (topic gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_topic_pt_trgm
|
||||||
|
ON public.pro_anomaly_briefs USING gin (topic_pt_br gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS pro_anomaly_briefs_doc_idx
|
||||||
|
ON public.pro_anomaly_briefs (doc_id) WHERE doc_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Grants — investigator role (least privilege)
|
||||||
|
ALTER TABLE public.pro_anomaly_briefs ENABLE ROW LEVEL SECURITY;
|
||||||
|
GRANT SELECT, INSERT ON public.pro_anomaly_briefs TO investigator;
|
||||||
|
GRANT USAGE, SELECT, UPDATE ON SEQUENCE public.brief_id_seq TO investigator;
|
||||||
|
GRANT USAGE, SELECT, UPDATE ON SEQUENCE public.pro_anomaly_briefs_brief_pk_seq TO investigator;
|
||||||
|
|
||||||
|
CREATE POLICY pro_anomaly_briefs_read ON public.pro_anomaly_briefs FOR SELECT USING (true);
|
||||||
|
CREATE POLICY pro_anomaly_briefs_insert ON public.pro_anomaly_briefs FOR INSERT
|
||||||
|
TO investigator, postgres, service_role WITH CHECK (true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
74
investigator-runtime/prompts/sun-tzu.md
Normal file
74
investigator-runtime/prompts/sun-tzu.md
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# You are the strategist (Sun-Tzu)
|
||||||
|
|
||||||
|
You are the disclosure-side analyst. The bureau's other tools (the
|
||||||
|
extractors, the contradiction-finders) ask "what does the record say?" —
|
||||||
|
you ask the harder question: **assuming the most anomalous reading is
|
||||||
|
true, what is the strongest case the corpus already supports?**
|
||||||
|
|
||||||
|
You are not a believer-by-default. You are an adversarial strategist who
|
||||||
|
plays the pro-disclosure side with the same rigour a red-team plays the
|
||||||
|
skeptic side. You build the case that would hold up if you had to brief
|
||||||
|
a serious officer who had only fifteen minutes and a copy of the file.
|
||||||
|
|
||||||
|
Output of this is INVISIBLE TO THE READER. The case-writer narrator will
|
||||||
|
pull from it silently. Do not address the reader. Do not name yourself.
|
||||||
|
|
||||||
|
## Discipline (non-negotiable)
|
||||||
|
|
||||||
|
1. **Every claim cites a chunk.** `[[doc-id/pNNN#cNNNN]]` next to each
|
||||||
|
specific assertion. If you cannot ground a claim in a chunk, drop it.
|
||||||
|
|
||||||
|
2. **No fabrication.** If the corpus does not contain a fact, you do not
|
||||||
|
import it from training-time knowledge. The brief is bounded by the
|
||||||
|
archive.
|
||||||
|
|
||||||
|
3. **One thesis, three pillars, one residual.** Structure:
|
||||||
|
- `thesis`: the single sentence the most anomalous reading reduces to.
|
||||||
|
- `pillars[]`: 2-4 load-bearing claims that hold up the thesis. Each
|
||||||
|
pillar is a paragraph (≤ 400 chars) with chunk citations.
|
||||||
|
- `unexplained`: 1-2 sentences naming what the corpus DOES NOT
|
||||||
|
resolve. This is honest residual, not a hedge — it's the part a
|
||||||
|
case-writer can use to close on the unknown.
|
||||||
|
|
||||||
|
4. **No skeptic ceremony in your prose.** You are not red-teaming. If a
|
||||||
|
skeptic counter exists in the corpus, you address it inside a pillar
|
||||||
|
("the analysts proposed X; the chunk records Y that X does not
|
||||||
|
account for") rather than as a separate counter-section.
|
||||||
|
|
||||||
|
5. **House style** (the prompt preamble above already enforces this):
|
||||||
|
no em-dash-as-comma, no rule-of-three lists, no "Moreover", no AI
|
||||||
|
vocab, no inflated symbolism.
|
||||||
|
|
||||||
|
## Output protocol — bilingual EN + PT-BR (mandatory)
|
||||||
|
|
||||||
|
Emit a strict JSON object. No prose around it. No code fence. Every
|
||||||
|
narrative field has its `_pt_br` sibling.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thesis": "EN one-sentence — the strongest pro-anomaly reading the corpus supports.",
|
||||||
|
"thesis_pt_br": "PT-BR uma frase — a leitura pró-anomalia mais forte que o corpus sustenta.",
|
||||||
|
"pillars": [
|
||||||
|
{
|
||||||
|
"claim": "EN one-sentence claim.",
|
||||||
|
"claim_pt_br": "PT-BR uma frase de afirmação.",
|
||||||
|
"support": "EN paragraph (≤ 400 chars) with [[doc-id/pNNN#cNNNN]] citations.",
|
||||||
|
"support_pt_br": "PT-BR parágrafo (≤ 400 chars) com [[doc-id/pNNN#cNNNN]] citações."
|
||||||
|
},
|
||||||
|
{ ... another pillar, also bilingual ... }
|
||||||
|
],
|
||||||
|
"unexplained": "EN 1-2 sentences — what the corpus does NOT resolve.",
|
||||||
|
"unexplained_pt_br": "PT-BR 1-2 frases — o que o corpus NÃO resolve."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- 2-4 pillars. Three is usually right. Two is fine when the case is
|
||||||
|
narrow. Avoid four unless each is genuinely independent.
|
||||||
|
- Every pillar's `support` field must contain at least one
|
||||||
|
`[[wiki-link]]` citation.
|
||||||
|
- A missing `_pt_br` sibling is a hard validation failure.
|
||||||
|
|
||||||
|
If the corpus simply does not support a non-trivial pro-anomaly
|
||||||
|
reading on this topic — emit `NO_STRONG_CASE` and stop. The narrator
|
||||||
|
will then write the case from the chunks alone, without your brief.
|
||||||
|
|
@ -60,6 +60,15 @@ interface GapRow {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BriefRow {
|
||||||
|
brief_id: string;
|
||||||
|
thesis: string;
|
||||||
|
thesis_pt_br: string;
|
||||||
|
pillars: Array<{ claim: string; claim_pt_br: string; support: string; support_pt_br: string }>;
|
||||||
|
unexplained: string;
|
||||||
|
unexplained_pt_br: string;
|
||||||
|
}
|
||||||
|
|
||||||
function topicSlug(topic: string): string {
|
function topicSlug(topic: string): string {
|
||||||
return topic
|
return topic
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -129,12 +138,33 @@ function renderNamedWitnesses(rows: WitnessRow[]): string {
|
||||||
].filter(Boolean).join("\n")).join("\n\n");
|
].filter(Boolean).join("\n")).join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderBrief(b: BriefRow | null, lang: "pt" | "en"): string {
|
||||||
|
if (!b) return "_(no strategic brief on file)_";
|
||||||
|
const thesis = lang === "pt" ? b.thesis_pt_br : b.thesis;
|
||||||
|
const unexplained = lang === "pt" ? b.unexplained_pt_br : b.unexplained;
|
||||||
|
const pillars = b.pillars.map((p, i) => {
|
||||||
|
const claim = lang === "pt" ? p.claim_pt_br : p.claim;
|
||||||
|
const support = lang === "pt" ? p.support_pt_br : p.support;
|
||||||
|
return `### Pillar ${i + 1}\n${claim}\n\n${support}`;
|
||||||
|
}).join("\n\n");
|
||||||
|
return [
|
||||||
|
`**Thesis (the strongest pro-anomaly reading the corpus supports):**`,
|
||||||
|
thesis,
|
||||||
|
"",
|
||||||
|
pillars,
|
||||||
|
"",
|
||||||
|
`**Unexplained (honest residual — strong closer material):**`,
|
||||||
|
unexplained,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function buildPrompt(
|
function buildPrompt(
|
||||||
task: CaseWriterTask,
|
task: CaseWriterTask,
|
||||||
scenes: SearchHit[],
|
scenes: SearchHit[],
|
||||||
evidence: EvidenceRow[],
|
evidence: EvidenceRow[],
|
||||||
witnesses: WitnessRow[],
|
witnesses: WitnessRow[],
|
||||||
gaps: GapRow[],
|
gaps: GapRow[],
|
||||||
|
brief: BriefRow | null,
|
||||||
lang: "pt" | "en",
|
lang: "pt" | "en",
|
||||||
): string {
|
): string {
|
||||||
return [
|
return [
|
||||||
|
|
@ -180,6 +210,16 @@ function buildPrompt(
|
||||||
"",
|
"",
|
||||||
renderNamedWitnesses(witnesses),
|
renderNamedWitnesses(witnesses),
|
||||||
"",
|
"",
|
||||||
|
"## Strategic brief (internal — do NOT cite or attribute)",
|
||||||
|
"",
|
||||||
|
"A separate analyst built the strongest pro-anomaly reading the corpus",
|
||||||
|
"supports. Use the thesis as a quiet through-line; weave the pillar",
|
||||||
|
"facts into your scenes; let the unexplained clause inform your closing",
|
||||||
|
"paragraph. DO NOT name the analyst, do not say 'a brief argues', do",
|
||||||
|
"not include the word 'thesis' or 'pillar' — translate it into prose.",
|
||||||
|
"",
|
||||||
|
renderBrief(brief, lang),
|
||||||
|
"",
|
||||||
"## Your task",
|
"## Your task",
|
||||||
"",
|
"",
|
||||||
"Write the case file per the system prompt: bilingual EN+PT-BR with",
|
"Write the case file per the system prompt: bilingual EN+PT-BR with",
|
||||||
|
|
@ -268,6 +308,19 @@ export async function runCaseWriter(task: CaseWriterTask): Promise<
|
||||||
[filter],
|
[filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pro-anomaly brief — most recent matching brief on this topic (or doc).
|
||||||
|
// The narrator never names this artefact; it weaves the thesis silently.
|
||||||
|
const briefRows = await query<BriefRow>(
|
||||||
|
`SELECT brief_id, thesis, thesis_pt_br, pillars, unexplained, unexplained_pt_br
|
||||||
|
FROM public.pro_anomaly_briefs
|
||||||
|
WHERE LOWER(topic) LIKE $1
|
||||||
|
OR LOWER(COALESCE(topic_pt_br,'')) LIKE $1
|
||||||
|
${docIdFilter ? "OR doc_id = $2" : ""}
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
docIdFilter ? [filter, docIdFilter] : [filter],
|
||||||
|
).catch(() => [] as BriefRow[]);
|
||||||
|
const brief = briefRows[0] ?? null;
|
||||||
|
|
||||||
await audit({
|
await audit({
|
||||||
event: "case_writer_grounded",
|
event: "case_writer_grounded",
|
||||||
job_id: task.job_id,
|
job_id: task.job_id,
|
||||||
|
|
@ -277,6 +330,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise<
|
||||||
n_evidence: evidence.length,
|
n_evidence: evidence.length,
|
||||||
n_witnesses: witnesses.length,
|
n_witnesses: witnesses.length,
|
||||||
n_gaps: gaps.length,
|
n_gaps: gaps.length,
|
||||||
|
brief_id: brief?.brief_id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refusal floor: the narrator needs real corpus material. Without enough
|
// Refusal floor: the narrator needs real corpus material. Without enough
|
||||||
|
|
@ -286,7 +340,7 @@ export async function runCaseWriter(task: CaseWriterTask): Promise<
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
|
const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
|
||||||
const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, lang);
|
const prompt = buildPrompt(task, scenes, evidence, witnesses, gaps, brief, lang);
|
||||||
|
|
||||||
// Case-writer wants more output budget than the other detectives.
|
// Case-writer wants more output budget than the other detectives.
|
||||||
const llm = await callClaude({
|
const llm = await callClaude({
|
||||||
|
|
|
||||||
177
investigator-runtime/src/detectives/sun_tzu.ts
Normal file
177
investigator-runtime/src/detectives/sun_tzu.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* sun_tzu.ts — pro-anomaly strategist (silent backend feeder).
|
||||||
|
*
|
||||||
|
* Given a topic, builds the strongest pro-anomaly brief the corpus
|
||||||
|
* supports: a thesis sentence, 2-4 citation-backed pillars, and an
|
||||||
|
* honest residual unexplained clause. The brief is stored in
|
||||||
|
* public.pro_anomaly_briefs and never surfaced reader-facing. The
|
||||||
|
* case-writer pulls it at narrative-assembly time.
|
||||||
|
*/
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { audit } from "../lib/audit";
|
||||||
|
import { callClaude } from "../lib/claude";
|
||||||
|
import { env } from "../lib/env";
|
||||||
|
import { hybridSearch, type SearchHit } from "../lib/search";
|
||||||
|
import {
|
||||||
|
writeProAnomalyBrief,
|
||||||
|
type WriteBriefArgs,
|
||||||
|
type BriefPillar,
|
||||||
|
} from "../tools/write_pro_anomaly_brief";
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PROMPT_PATH = path.resolve(HERE, "..", "..", "prompts", "sun-tzu.md");
|
||||||
|
|
||||||
|
export interface SunTzuTask {
|
||||||
|
job_id: string;
|
||||||
|
topic: string;
|
||||||
|
topic_pt_br?: string;
|
||||||
|
doc_id?: string;
|
||||||
|
lang?: "pt" | "en";
|
||||||
|
context_chunks?: number;
|
||||||
|
budget_cap_usd?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChunkBlock(hits: SearchHit[], lang: "pt" | "en"): string {
|
||||||
|
return hits.map((h, i) => {
|
||||||
|
const text = (lang === "en" ? h.content_en : h.content_pt) || h.content_en || h.content_pt || "";
|
||||||
|
return [
|
||||||
|
`--- chunk ${i + 1} ---`,
|
||||||
|
`doc_id: ${h.doc_id}`,
|
||||||
|
`chunk_id: ${h.chunk_id}`,
|
||||||
|
`page: ${h.page}`,
|
||||||
|
`type: ${h.type}`,
|
||||||
|
h.classification ? `classification: ${h.classification}` : null,
|
||||||
|
"",
|
||||||
|
text.slice(0, 1100),
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
}).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(task: SunTzuTask, hits: SearchHit[], lang: "pt" | "en"): string {
|
||||||
|
return [
|
||||||
|
`# Topic`,
|
||||||
|
"",
|
||||||
|
`**EN.** ${task.topic}`,
|
||||||
|
`**PT-BR.** ${task.topic_pt_br ?? task.topic}`,
|
||||||
|
task.doc_id ? `\nScope: ${task.doc_id}` : "",
|
||||||
|
"",
|
||||||
|
`## Corpus shortlist (${hits.length} chunks)`,
|
||||||
|
"",
|
||||||
|
renderChunkBlock(hits, lang),
|
||||||
|
"",
|
||||||
|
"## Your task",
|
||||||
|
"",
|
||||||
|
"Build the strongest pro-anomaly brief the chunks above support.",
|
||||||
|
"Output the strict JSON object per the system prompt. Bilingual is",
|
||||||
|
"mandatory. If the corpus does not support a non-trivial pro-anomaly",
|
||||||
|
"reading, emit `NO_STRONG_CASE` and stop.",
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonObject(text: string): Record<string, unknown> | null {
|
||||||
|
const t = text.trim();
|
||||||
|
if (/^`?NO_STRONG_CASE`?\b/i.test(t)) return null;
|
||||||
|
const stripped = t.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
||||||
|
const first = stripped.indexOf("{");
|
||||||
|
const last = stripped.lastIndexOf("}");
|
||||||
|
if (first === -1 || last === -1) throw new Error(`no JSON object: ${t.slice(0, 200)}`);
|
||||||
|
const parsed = JSON.parse(stripped.slice(first, last + 1));
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("JSON is not an object");
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coercePillars(raw: unknown): BriefPillar[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: BriefPillar[] = [];
|
||||||
|
for (const p of raw) {
|
||||||
|
if (!p || typeof p !== "object") continue;
|
||||||
|
const o = p as Record<string, unknown>;
|
||||||
|
const claim = typeof o.claim === "string" ? o.claim.trim() : "";
|
||||||
|
const claim_pt_br = typeof o.claim_pt_br === "string" ? o.claim_pt_br.trim() : "";
|
||||||
|
const support = typeof o.support === "string" ? o.support.trim() : "";
|
||||||
|
const support_pt_br = typeof o.support_pt_br === "string" ? o.support_pt_br.trim() : "";
|
||||||
|
if (!claim || !claim_pt_br || !support || !support_pt_br) continue;
|
||||||
|
out.push({ claim, claim_pt_br, support, support_pt_br });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSunTzu(task: SunTzuTask): Promise<
|
||||||
|
| { brief_id: string }
|
||||||
|
| { skipped: true; reason: string }
|
||||||
|
> {
|
||||||
|
const lang: "pt" | "en" = task.lang ?? "pt";
|
||||||
|
const k = task.context_chunks ?? 18;
|
||||||
|
|
||||||
|
const hits = await hybridSearch({
|
||||||
|
query: task.topic,
|
||||||
|
lang,
|
||||||
|
doc_id: task.doc_id ?? null,
|
||||||
|
top_k: k,
|
||||||
|
recall_k: 80,
|
||||||
|
max_dense_dist: 0.55,
|
||||||
|
});
|
||||||
|
await audit({
|
||||||
|
event: "sun_tzu_grounded",
|
||||||
|
job_id: task.job_id,
|
||||||
|
detective: "strategist@detective",
|
||||||
|
topic: task.topic,
|
||||||
|
n_chunks: hits.length,
|
||||||
|
doc_id: task.doc_id ?? null,
|
||||||
|
});
|
||||||
|
if (hits.length < 4) {
|
||||||
|
return { skipped: true, reason: `insufficient_corpus_${hits.length}_of_4` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = await readFile(PROMPT_PATH, "utf-8");
|
||||||
|
const llm = await callClaude({
|
||||||
|
prompt: buildPrompt(task, hits, lang),
|
||||||
|
systemPrompt,
|
||||||
|
model: env.CLAUDE_MODEL,
|
||||||
|
allowedTools: [],
|
||||||
|
timeoutMs: env.JOB_TIMEOUT_SECONDS * 1000,
|
||||||
|
budgetCapUsd: task.budget_cap_usd ?? env.BUDGET_CAP_USD_PER_JOB,
|
||||||
|
});
|
||||||
|
await audit({
|
||||||
|
event: "detective_completed",
|
||||||
|
job_id: task.job_id,
|
||||||
|
detective: "strategist@detective",
|
||||||
|
cost_usd: llm.costUsd,
|
||||||
|
tokens_in: llm.tokensIn,
|
||||||
|
tokens_out: llm.tokensOut,
|
||||||
|
duration_ms: llm.durationMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const obj = extractJsonObject(llm.text);
|
||||||
|
if (obj === null) return { skipped: true, reason: "NO_STRONG_CASE" };
|
||||||
|
|
||||||
|
const args: WriteBriefArgs = {
|
||||||
|
topic: task.topic,
|
||||||
|
topic_pt_br: task.topic_pt_br ?? task.topic,
|
||||||
|
doc_id: task.doc_id,
|
||||||
|
thesis: typeof obj.thesis === "string" ? obj.thesis.trim() : "",
|
||||||
|
thesis_pt_br: typeof obj.thesis_pt_br === "string" ? obj.thesis_pt_br.trim() : "",
|
||||||
|
pillars: coercePillars(obj.pillars),
|
||||||
|
unexplained: typeof obj.unexplained === "string" ? obj.unexplained.trim() : "",
|
||||||
|
unexplained_pt_br: typeof obj.unexplained_pt_br === "string" ? obj.unexplained_pt_br.trim() : "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await writeProAnomalyBrief(args, {
|
||||||
|
job_id: task.job_id,
|
||||||
|
detective: "strategist@detective",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
await audit({
|
||||||
|
event: "write_pro_anomaly_brief_failed",
|
||||||
|
job_id: task.job_id,
|
||||||
|
detective: "strategist@detective",
|
||||||
|
error: (e as Error).message,
|
||||||
|
});
|
||||||
|
return { skipped: true, reason: `write_failed: ${(e as Error).message.slice(0, 80)}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,4 +21,5 @@ export const allocate = {
|
||||||
witnessId: () => nextval("public.witness_id_seq", "W"),
|
witnessId: () => nextval("public.witness_id_seq", "W"),
|
||||||
gapId: () => nextval("public.gap_id_seq", "G"),
|
gapId: () => nextval("public.gap_id_seq", "G"),
|
||||||
residualUncertaintyId: () => nextval("public.residual_uncertainty_id_seq","RU"),
|
residualUncertaintyId: () => nextval("public.residual_uncertainty_id_seq","RU"),
|
||||||
|
briefId: () => nextval("public.brief_id_seq", "B"),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { runPoirot, type PoirotTask } from "./detectives/poirot";
|
||||||
import { runTaleb, type TalebTask } from "./detectives/taleb";
|
import { runTaleb, type TalebTask } from "./detectives/taleb";
|
||||||
import { runTetlock, type TetlockTask } from "./detectives/tetlock";
|
import { runTetlock, type TetlockTask } from "./detectives/tetlock";
|
||||||
import { runCaseWriter, type CaseWriterTask } from "./detectives/case_writer";
|
import { runCaseWriter, type CaseWriterTask } from "./detectives/case_writer";
|
||||||
|
import { runSunTzu, type SunTzuTask } from "./detectives/sun_tzu";
|
||||||
|
|
||||||
export interface InvestigationJob {
|
export interface InvestigationJob {
|
||||||
job_id: string;
|
job_id: string;
|
||||||
|
|
@ -75,6 +76,25 @@ export async function dispatch(job: InvestigationJob, workerId: string): Promise
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "anomaly_brief": {
|
||||||
|
// Payload: { topic, topic_pt_br?, doc_id?, lang? }
|
||||||
|
const topic = String(job.payload.topic ?? "").trim();
|
||||||
|
if (!topic) throw new Error("anomaly_brief requires payload.topic");
|
||||||
|
const task: SunTzuTask = {
|
||||||
|
job_id: job.job_id, topic,
|
||||||
|
topic_pt_br: typeof job.payload.topic_pt_br === "string"
|
||||||
|
? job.payload.topic_pt_br.trim() : undefined,
|
||||||
|
doc_id: typeof job.payload.doc_id === "string" ? job.payload.doc_id : undefined,
|
||||||
|
lang: job.payload.lang === "en" ? "en" : "pt",
|
||||||
|
};
|
||||||
|
const r = await runSunTzu(task);
|
||||||
|
if ("skipped" in r) {
|
||||||
|
outputs.push({ kind: "anomaly_brief", skipped: true, reason: r.reason });
|
||||||
|
} else {
|
||||||
|
outputs.push({ kind: "anomaly_brief", brief_id: r.brief_id });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "case_report": {
|
case "case_report": {
|
||||||
// Payload: { topic, topic_pt_br?, doc_id?, slug?, lang? }
|
// Payload: { topic, topic_pt_br?, doc_id?, slug?, lang? }
|
||||||
const topic = String(job.payload.topic ?? "").trim();
|
const topic = String(job.payload.topic ?? "").trim();
|
||||||
|
|
|
||||||
83
investigator-runtime/src/tools/write_pro_anomaly_brief.ts
Normal file
83
investigator-runtime/src/tools/write_pro_anomaly_brief.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* write_pro_anomaly_brief.ts — Sun-Tzu's primary writer.
|
||||||
|
*
|
||||||
|
* Inserts a row into public.pro_anomaly_briefs. The brief is INTERNAL —
|
||||||
|
* the case-writer reads it at assembly time but the reader never sees
|
||||||
|
* it directly.
|
||||||
|
*/
|
||||||
|
import { audit } from "../lib/audit";
|
||||||
|
import { allocate } from "../lib/ids";
|
||||||
|
import { queryOne } from "../lib/pg";
|
||||||
|
|
||||||
|
export interface BriefPillar {
|
||||||
|
claim: string;
|
||||||
|
claim_pt_br: string;
|
||||||
|
support: string;
|
||||||
|
support_pt_br: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteBriefArgs {
|
||||||
|
topic: string;
|
||||||
|
topic_pt_br?: string;
|
||||||
|
doc_id?: string;
|
||||||
|
thesis: string;
|
||||||
|
thesis_pt_br: string;
|
||||||
|
pillars: BriefPillar[];
|
||||||
|
unexplained: string;
|
||||||
|
unexplained_pt_br: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WriteBriefContext {
|
||||||
|
job_id: string;
|
||||||
|
detective: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeProAnomalyBrief(
|
||||||
|
body: WriteBriefArgs,
|
||||||
|
ctx: WriteBriefContext,
|
||||||
|
): Promise<{ brief_id: string; pk: number }> {
|
||||||
|
if (!body.topic?.trim()) throw new Error("topic required");
|
||||||
|
if (!body.thesis?.trim()) throw new Error("thesis required");
|
||||||
|
if (!body.thesis_pt_br?.trim()) throw new Error("thesis_pt_br required");
|
||||||
|
if (!body.unexplained?.trim()) throw new Error("unexplained required");
|
||||||
|
if (!body.unexplained_pt_br?.trim()) throw new Error("unexplained_pt_br required");
|
||||||
|
if (!Array.isArray(body.pillars) || body.pillars.length < 2 || body.pillars.length > 4) {
|
||||||
|
throw new Error(`pillars must be 2-4 entries (got ${body.pillars?.length ?? 0})`);
|
||||||
|
}
|
||||||
|
for (const p of body.pillars) {
|
||||||
|
if (!p?.claim?.trim() || !p?.claim_pt_br?.trim() || !p?.support?.trim() || !p?.support_pt_br?.trim()) {
|
||||||
|
throw new Error("each pillar requires bilingual claim + support");
|
||||||
|
}
|
||||||
|
if (!/\[\[/.test(p.support) && !/\[\[/.test(p.support_pt_br)) {
|
||||||
|
throw new Error("each pillar's support must contain at least one [[wiki-link]] citation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brief_id = await allocate.briefId();
|
||||||
|
const row = await queryOne<{ brief_pk: number }>(
|
||||||
|
`INSERT INTO public.pro_anomaly_briefs
|
||||||
|
(brief_id, topic, topic_pt_br, doc_id, thesis, thesis_pt_br,
|
||||||
|
pillars, unexplained, unexplained_pt_br, created_by, job_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11)
|
||||||
|
RETURNING brief_pk`,
|
||||||
|
[
|
||||||
|
brief_id, body.topic.trim(), body.topic_pt_br?.trim() ?? null,
|
||||||
|
body.doc_id ?? null, body.thesis.trim(), body.thesis_pt_br.trim(),
|
||||||
|
JSON.stringify(body.pillars),
|
||||||
|
body.unexplained.trim(), body.unexplained_pt_br.trim(),
|
||||||
|
ctx.detective, ctx.job_id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await audit({
|
||||||
|
event: "write_pro_anomaly_brief",
|
||||||
|
job_id: ctx.job_id,
|
||||||
|
detective: ctx.detective,
|
||||||
|
brief_id,
|
||||||
|
pk: row?.brief_pk,
|
||||||
|
n_pillars: body.pillars.length,
|
||||||
|
topic: body.topic.slice(0, 120),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { brief_id, pk: row?.brief_pk ?? 0 };
|
||||||
|
}
|
||||||
|
|
@ -112,6 +112,18 @@ export default async function EntityPage({
|
||||||
const classColor = CLASS_COLOR[folder as EntityClass];
|
const classColor = CLASS_COLOR[folder as EntityClass];
|
||||||
const classBg = CLASS_BG[folder as EntityClass];
|
const classBg = CLASS_BG[folder as EntityClass];
|
||||||
|
|
||||||
|
// Hero illustration — W5.4 generates one painterly editorial image per
|
||||||
|
// iconic entity, stored at /data/ufo/processing/case-art/<entity_id>.png
|
||||||
|
// and served via the existing /api/static/processing/ route.
|
||||||
|
let heroIllustration: string | null = null;
|
||||||
|
try {
|
||||||
|
const fs = await import("node:fs/promises");
|
||||||
|
const path = await import("node:path");
|
||||||
|
const ufoRoot = process.env.UFO_ROOT || "/data/ufo";
|
||||||
|
await fs.stat(path.join(ufoRoot, "processing", "case-art", `${id}.png`));
|
||||||
|
heroIllustration = `/api/static/processing/case-art/${id}.png`;
|
||||||
|
} catch { /* no illustration for this entity yet */ }
|
||||||
|
|
||||||
// The generated entity bodies hold only "# Title" + empty "## Description"
|
// The generated entity bodies hold only "# Title" + empty "## Description"
|
||||||
// headings — strip headings and see if any real prose remains.
|
// headings — strip headings and see if any real prose remains.
|
||||||
const bodyProse = (wiki?.body ?? "").replace(/^#.*$/gm, "").trim();
|
const bodyProse = (wiki?.body ?? "").replace(/^#.*$/gm, "").trim();
|
||||||
|
|
@ -144,8 +156,36 @@ export default async function EntityPage({
|
||||||
<AuthBar />
|
<AuthBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero header */}
|
{/* Full-bleed painterly hero — appears only when this entity has a
|
||||||
<header
|
generated illustration. Otherwise the existing header card stays. */}
|
||||||
|
{heroIllustration && (
|
||||||
|
<div className="relative -mx-6 md:-mx-10 mb-8 overflow-hidden border-y border-[rgba(224,192,128,0.20)]">
|
||||||
|
<div className="relative aspect-[16/9] md:aspect-[21/9] max-h-[520px]">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={heroIllustration}
|
||||||
|
alt={canonical}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#020409] via-[#020409]/30 to-transparent" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-[#020409]/60 via-transparent to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 px-6 md:px-10 pb-6 md:pb-10">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[#e0c080] mb-2">
|
||||||
|
{CLASS_TITLE[folder as EntityClass]} · /e/{folder}/{id}
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-4xl md:text-6xl font-semibold leading-[1.05] tracking-tight text-white drop-shadow-lg">
|
||||||
|
{canonical}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 right-3 text-[9px] font-mono uppercase tracking-wider text-[#9aa6b8]/80 bg-[#020409]/70 px-2 py-1 rounded">
|
||||||
|
Ilustração editorial
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hero header — shown only when there's no illustration above */}
|
||||||
|
{!heroIllustration && <header
|
||||||
className={`mb-8 p-6 rounded-lg border-2 bg-gradient-to-br to-[#040810] ${classBg} ${classColor.split(" ")[1]}`}
|
className={`mb-8 p-6 rounded-lg border-2 bg-gradient-to-br to-[#040810] ${classBg} ${classColor.split(" ")[1]}`}
|
||||||
>
|
>
|
||||||
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2 flex items-center gap-3 flex-wrap">
|
<div className="font-mono text-[10px] text-[#5a6678] tracking-widest uppercase mb-2 flex items-center gap-3 flex-wrap">
|
||||||
|
|
@ -241,7 +281,7 @@ export default async function EntityPage({
|
||||||
ela. Pode ser extração ruidosa do pipeline original.
|
ela. Pode ser extração ruidosa do pipeline original.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8">
|
||||||
{/* MAIN — narrative + chunks live */}
|
{/* MAIN — narrative + chunks live */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue