Files
infiplot-web/lib/engine/agents/architect.ts
T
DESKTOP-I1T6TF3\Q 37c911f510 chore(engine): log prompt-cache hit/miss per chat call
Add a `tag` option to chat() and have it print one `[cache] <tag>
hit=X miss=Y rate=Z%` line per call. Three Usage-shape variants are
probed in order so the same logger works across providers:

  - DeepSeek (v3+):  usage.prompt_cache_hit_tokens / *_miss_tokens
  - OpenAI / o-series: usage.prompt_tokens_details.cached_tokens
  - Anthropic:        usage.cache_read_input_tokens / *_creation_*

When none of them are present (MiMo / local Ollama / others) we still
print prompt + completion totals so the cost baseline is visible.

Tag every callsite so the log is greppable:
  architect / writer / character-designer / cinematographer / insert-beat

This is the prerequisite for the prefix-cache reordering work that
follows — without per-agent visibility there's no way to tell if a
prompt rearrangement actually moved the needle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 10:42:33 +08:00

91 lines
3.7 KiB
TypeScript

import { chat } from "@infiplot/ai-client";
import type { ProviderConfig, Session, StoryState } from "@infiplot/types";
import { parseJsonLoose } from "../jsonParser";
import { ARCHITECT_SYSTEM, buildArchitectUserMessage } from "../prompts";
// ──────────────────────────────────────────────────────────────────────
// Architect agent — ONE LLM call at session start.
//
// Expands the user's (often terse) world + style prompt into a real story
// bible: a second-person protagonist with a want and a flaw, a single
// central dramatic question (logline), a genre frame that anchors the
// 爽点 rhythm, an engineered cold-open for scene 1 (nextHook), and a small
// intentional cast. Seeds the StoryState that the Writer reads and updates
// every scene — so the story has a spine from beat one instead of being
// improvised cold.
//
// Everything is best-effort coerced with fallbacks: a malformed LLM
// response can never abort session start — worst case the Writer just gets
// a thinner bible and improvises more.
// ──────────────────────────────────────────────────────────────────────
type RawStoryState = {
logline?: unknown;
genreTags?: unknown;
protagonist?: unknown;
castNotes?: unknown;
synopsis?: unknown;
openThreads?: unknown;
relationships?: unknown;
nextHook?: unknown;
};
function str(raw: unknown): string {
return typeof raw === "string" ? raw.trim() : "";
}
function strArray(raw: unknown): string[] | undefined {
if (!Array.isArray(raw)) return undefined;
const out = raw
.map((x) => (typeof x === "string" ? x.trim() : ""))
.filter((x) => x.length > 0);
return out.length > 0 ? out : undefined;
}
export async function runArchitect(
config: ProviderConfig,
session: Session,
): Promise<StoryState> {
try {
const raw = await chat(
config,
[
{ role: "system", content: ARCHITECT_SYSTEM },
{ role: "user", content: buildArchitectUserMessage(session) },
],
{ temperature: 0.85, responseFormat: "json_object", tag: "architect" },
);
const parsed = parseJsonLoose<RawStoryState>(raw);
return {
// Stable spine — fall back to the raw world/style prompt so the bible is
// never wholly empty even if the model returns garbage.
logline: str(parsed.logline) || session.worldSetting,
genreTags: str(parsed.genreTags),
protagonist:
str(parsed.protagonist) ||
"你是这个故事的主角(第二人称视角,永不出现在画面里)。",
castNotes: str(parsed.castNotes) || undefined,
// Volatile seeds — the opening Writer will rewrite these via its patch.
synopsis: str(parsed.synopsis) || "故事即将开始。",
openThreads: strArray(parsed.openThreads),
relationships: strArray(parsed.relationships),
nextHook: str(parsed.nextHook) || undefined,
};
} catch (err) {
// chat() or parseJsonLoose() can throw (network / unrepairable JSON).
// The Architect is best-effort: never let it abort session start — return
// a minimal bible seeded from the raw prompt and let the Writer improvise.
const msg = err instanceof Error ? err.message : String(err);
console.error(`[architect] failed, using minimal bible: ${msg}`);
return {
logline: session.worldSetting,
genreTags: "",
protagonist:
"你是这个故事的主角(第二人称视角,永不出现在画面里)。",
synopsis: "故事即将开始。",
};
}
}