Files
writer-work-flow/apps/web/lib/style/style.ts
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。
M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变);
  网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则;
  前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。
K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径;
  coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。
本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400);
  GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化;
  字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。
门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:39:58 +02:00

130 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 文风纯逻辑指纹归一dims+evidence、漂移归一从 chapter_reviews.style
// 学文风/回炉请求体组装。纯逻辑,便于 node 环境单测C3 扩 T4.3 / C4 扩 T4.2)。
import type {
ReviewHistoryItem,
StyleFingerprintResponse,
StyleLearnRequest,
RefineRequest,
} from "@/lib/api/types";
import type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
export type { StyleDriftReport, StyleDriftSegment } from "@/lib/review/sse";
// ── 指纹16 维 + 证据UX §6.9)─────────────────────────────────
// 后端 dimensions={name:value}、evidence={name:[quotes]}(松散 JSONB
export interface FingerprintDimension {
name: string;
value: string;
evidence: string[];
}
export interface Fingerprint {
dimensions: FingerprintDimension[];
version: number;
}
function asStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === "string");
}
function asDisplayValue(v: unknown): string {
if (typeof v === "string") return v;
if (typeof v === "number" || typeof v === "boolean") return String(v);
return "";
}
// 把松散 GET /style 响应收窄成 Fingerprint。
// dimensions 的 key 顺序即维度顺序身份evidence 按维度名对齐。
export function normalizeFingerprint(
resp: StyleFingerprintResponse | undefined,
): Fingerprint | null {
if (!resp) return null;
const dims = resp.dimensions ?? {};
const evid = resp.evidence ?? {};
const names = Object.keys(dims);
const dimensions: FingerprintDimension[] = names.map((name) => ({
name,
value: asDisplayValue(dims[name]),
evidence: asStringArray(evid[name]),
}));
return { dimensions, version: resp.version };
}
// ── 漂移第四审C4 扩 T4.2)────────────────────────────────────
// chapter_reviews.style = {score:int, segments:[{idx,score,label?}]}。
// 类型 StyleDriftReport/StyleDriftSegment 在 lib/review/sse.tsreduce 复用),上面已 re-export。
function asInt(v: unknown, fallback = 0): number {
return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : fallback;
}
function asOptionalString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
// 把松散 style dict 收窄成漂移报告;缺失/非 dict → null不渲染
export function normalizeStyleDrift(
item: ReviewHistoryItem | undefined,
): StyleDriftReport | null {
const raw = item?.style;
if (typeof raw !== "object" || raw === null) return null;
const dict = raw as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
// score 缺省 100无指纹降级态对齐后端默认
return { score: asInt(dict["score"], 100), segments };
}
// 同 style SSE 事件 data 也用此收窄reduceReview case "style" 复用)。
export function narrowStyleEvent(data: unknown): StyleDriftReport {
if (typeof data !== "object" || data === null) {
return { score: 100, segments: [] };
}
const dict = data as Record<string, unknown>;
const segRaw = Array.isArray(dict["segments"]) ? dict["segments"] : [];
const segments: StyleDriftSegment[] = segRaw
.filter((s): s is Record<string, unknown> => typeof s === "object" && s !== null)
.map((s) => ({
idx: asInt(s["idx"]),
score: asInt(s["score"], 100),
label: asOptionalString(s["label"]),
}));
return { score: asInt(dict["score"], 100), segments };
}
// ── 请求体组装 ──────────────────────────────────────────────────
export type StyleLearnMode = "create" | "update";
// 组装学文风请求体去掉空白样本mode 透传(首学/更新仅前端语义)。
export function buildLearnRequest(
samples: readonly string[],
mode: StyleLearnMode,
): StyleLearnRequest {
const cleaned = samples.map((s) => s.trim()).filter((s) => s.length > 0);
return { samples: cleaned, mode };
}
// 至少一条非空样本才可提交(对齐后端 min_length=1
export function hasUsableSamples(samples: readonly string[]): boolean {
return samples.some((s) => s.trim().length > 0);
}
// 组装回炉请求体(段去空白;空指令省略)。
export function buildRefineRequest(
segment: string,
instruction?: string | null,
): RefineRequest {
const body: RefineRequest = { segment: segment.trim() };
const trimmed = instruction?.trim();
if (trimmed) body.instruction = trimmed;
return body;
}