From 8b950f3bd4fd8e2f4cfb431f2146ae76f7923802 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 1/7] feat(derive): derive stats from the ledger, deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:feat(derive): derive stats from the ledger, deterministically --- src/derive/retrospect.mjs | 1008 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1008 insertions(+) create mode 100644 src/derive/retrospect.mjs diff --git a/src/derive/retrospect.mjs b/src/derive/retrospect.mjs new file mode 100644 index 00000000..8d96f1a6 --- /dev/null +++ b/src/derive/retrospect.mjs @@ -0,0 +1,1008 @@ +/** + * `retrospect` — turn the local ledger into citable conclusions. + * + * The whole point of this module is that a claim is only allowed to exist if it + * can be pointed back at the bytes it came from. Concretely: + * + * - **Pure and deterministic.** No clock, no randomness, no network, no model. + * Every number is a function of the input files alone, every object key is + * written in sorted order and every float is rounded to a fixed number of + * digits, so running twice over the same ledger produces byte-identical + * files (`docs/v2/CONTRACT.md` §5). + * - **Evidence only from the ledger.** A claim may cite an anchor only if + * `knowledge/index.json` declares that anchor, so the mechanical + * anchor-resolution assertion in `docs/v2/ACCEPTANCE.md` §5 cannot fail by + * construction. When the ledger only declares a paragraph-level anchor while + * the text carries turn-level anchors, the units are *merged up* to the + * granularity the ledger can actually cite, and a note says so. + * - **Empty beats invented.** Every dimension has a minimum sample size. Below + * it the file ships `claims: []` plus a `notes` entry explaining exactly + * which threshold was missed — never a guessed value. + * + * Layout produced (relative to the person directory): + * + * evidence/derived/{stats,voice,relations,timeline,boundaries,shifts,conflicts}.json + * + * Each file is `{kind, generated_from: [{path, sha256}], claims: [...], notes: []}` + * and each claim is `{id, label: {zh, en}, value, confidence, evidence: [...]}`. + * + * Claim id naming rule (frozen for `ds/03-render`): `.` for a + * recurring measurement, `..` for a named bucket and + * `.candidate_` for an ordered candidate list, where `NNNN` is the + * 1-based position in that file's deterministic order. There is never a bare + * `` id and ids never carry a timestamp or a random component. + */ + +import { + deriveBoundaries, + deriveConflicts, + deriveRelations, + deriveShifts, + deriveTimeline, + deriveVoice, +} from "./dimensions.mjs"; +import { createHash } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +/** The seven derived files, in the order the contract lists them. */ +export const DERIVED_KINDS = [ + "stats", + "voice", + "relations", + "timeline", + "boundaries", + "shifts", + "conflicts", +]; + +/** + * Dimensions are skipped, not guessed, below these sample sizes. The numbers + * are deliberately low enough that a 38-cue interview still yields a useful + * file, and high enough that two messages yield nothing. + */ +export const MIN_UNITS = { + any: 8, // below this every dimension is empty and says why + participants: 2, // a 1:1 chat has exactly two speakers and must still report them + timeSpan: 2, // units carrying a parseable timestamp + lengths: 5, + density: 2, + sentences: 10, + punctuation: 5, + emoji: 5, + ngram: 20, + address: 5, + questions: 10, + interactions: 10, + latency: 6, + phases: 12, + boundaries: 20, + shifts: 16, + conflicts: 10, +}; + +/** Sliding-window width for `shifts`, as a fraction of the corpus. */ +const SHIFT_WINDOW_RATIO = 0.18; +const SHIFT_WINDOW_MIN = 5; +const SHIFT_WINDOW_MAX = 12; +/** A shift point must clear both a relative and an absolute threshold. */ +const SHIFT_RELATIVE = 0.45; +const SHIFT_ABSOLUTE = 6; + +/** One anchor, as frozen in `docs/v2/CONTRACT.md`: `k0012` or `k0012:t3`. */ +const ANCHOR_PATTERN = /k\d{4,}(?::t\d+)?/; +const ANCHOR_PATTERN_GLOBAL = /k\d{4,}(?::t\d+)?/g; +const ANCHOR_BRACKETS = /\[(k\d{4,}(?::t\d+)?)\]/g; +/** + * The anchor forms that actually occur. `[k0012]` / `[k0012:t3]` is the shape + * this module's contract describes and the shape the acceptance corpus uses; + * `k0012 text` (bare, no brackets) is what `src/knowledge/anchors.mjs` renders + * into `knowledge/text/*.md`. Both are read, and the parser never invents an + * anchor that is not literally present in the line. + */ +const ANCHOR_LEADING = /^\s*(k\d{4,}(?::t\d+)?)(?=\s|$)/; + +/** `2024-03-04T09:02:00Z`, `2024-03-04 09:02`, `2024-03-04T09:02:00+08:00`. */ +const LEADING_TIMESTAMP = + /^\s*(\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?)\s*/; + +/** + * A bare timecode, as subtitle parsers render it ("00:00:01.000"). + * + * It is consumed but does **not** become `at`: a timecode is an offset inside one + * file, not an instant on a calendar, and pretending otherwise would let a + * timeline claim dates the corpus never carried. + */ +const LEADING_TIMECODE = /^\s*(\d{1,3}:\d{2}:\d{2}(?:[.,]\d{1,3})?)\s*/; + +/** `林工:` / `interviewer: ` at the head of a turn. */ +const LEADING_SPEAKER = /^([^\d::\n][^::\n]{0,23})[::]\s*/; + +// --------------------------------------------------------------------------- +// deterministic primitives +// --------------------------------------------------------------------------- + +/** Sorted-key JSON. The run-twice-same-sha256 gate rests on this. */ +export function stableStringify(value, indent = 2) { + const normalise = (input) => { + if (input === null || typeof input !== "object") return input; + if (Array.isArray(input)) return input.map(normalise); + const output = {}; + for (const key of Object.keys(input).sort()) { + if (input[key] === undefined) continue; + output[key] = normalise(input[key]); + } + return output; + }; + return JSON.stringify(normalise(value), null, indent); +} + +/** Fixed-precision floats: the same ratio always serialises to the same text. */ +export function round(value, digits = 4) { + if (!Number.isFinite(value)) return null; + const factor = 10 ** digits; + const scaled = Math.round(value * factor) / factor; + return Object.is(scaled, -0) ? 0 : scaled; +} + +export function sha256Hex(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function countChars(text) { + return [...text].length; +} + +function median(sortedNumbers) { + if (sortedNumbers.length === 0) return null; + const middle = Math.floor(sortedNumbers.length / 2); + return sortedNumbers.length % 2 === 1 + ? sortedNumbers[middle] + : (sortedNumbers[middle - 1] + sortedNumbers[middle]) / 2; +} + +function percentile(sortedNumbers, fraction) { + if (sortedNumbers.length === 0) return null; + return sortedNumbers[percentileIndex(sortedNumbers.length, fraction)]; +} + +function percentileIndex(length, fraction) { + return Math.min(length - 1, Math.max(0, Math.ceil(fraction * length) - 1)); +} + +function mean(numbers) { + if (numbers.length === 0) return null; + return numbers.reduce((total, value) => total + value, 0) / numbers.length; +} + +function unique(values) { + return [...new Set(values)]; +} + +/** Split a list into `parts` contiguous chunks that differ in length by <= 1. */ +function chunk(values, parts) { + const size = Math.ceil(values.length / parts); + const chunks = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +/** Up to `max` items, evenly spread across the list, order preserved. */ +function spread(values, max) { + if (values.length <= max) return values.slice(); + if (max <= 1) return [values[0]]; + const picked = []; + for (let index = 0; index < max; index += 1) { + picked.push(values[Math.round((index * (values.length - 1)) / (max - 1))]); + } + return unique(picked); +} + +/** `[k, t]`, so anchors order the way a human reads the ledger. */ +function anchorRank(anchor) { + const match = /^k(\d+)(?::t(\d+))?$/.exec(anchor); + if (!match) return [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; + return [Number(match[1]), match[2] === undefined ? 0 : Number(match[2])]; +} + +export function compareAnchors(left, right) { + const [leftK, leftT] = anchorRank(left); + const [rightK, rightT] = anchorRank(right); + if (leftK !== rightK) return leftK - rightK; + if (leftT !== rightT) return leftT - rightT; + return left < right ? -1 : left > right ? 1 : 0; +} + +function sortedAnchors(anchors) { + return unique(anchors).sort(compareAnchors); +} + +/** + * Confidence rules — the only three values allowed are `high`, `medium`, `low`. + * + * high >= 40 units of sample AND >= 3 independent anchors cited + * medium >= 15 units of sample AND >= 2 independent anchors cited + * low everything else that still cleared the dimension's minimum + * + * A dimension that cannot cite even one anchor is not emitted at all, so a + * claim never carries `evidence: []`. + */ +function confidenceOf(sample, evidenceCount) { + if (sample >= 40 && evidenceCount >= 3) return "high"; + if (sample >= 15 && evidenceCount >= 2) return "medium"; + return "low"; +} + +/** + * Build a claim. Returns `null` when there is nothing to cite, so a claim can + * never reach the output with `evidence: []` — the assembly step drops the + * `null`s. `fallback` is a last resort for measurements whose natural anchor is + * not guaranteed to exist (a median that lands between two samples, say). + */ +function makeClaim(id, zh, en, value, evidence, sample, fallback = []) { + let anchors = sortedAnchors(evidence); + if (anchors.length === 0) anchors = sortedAnchors(fallback); + if (anchors.length === 0) return null; + return { + id, + label: { zh, en }, + value, + confidence: confidenceOf(sample, anchors.length), + evidence: anchors.slice(0, 8), + }; +} + +/** `zh / en`, so a single `notes` string stays readable in both languages. */ +function note(zh, en) { + return `${zh} / ${en}`; +} + +// --------------------------------------------------------------------------- +// reading the ledger +// --------------------------------------------------------------------------- + +/** Normalise one `anchors` element: a string, or an object with an id. */ +function anchorIdOf(entry) { + if (typeof entry === "string") return entry; + if (entry && typeof entry === "object") { + for (const key of ["id", "anchor", "ref", "value"]) { + if (typeof entry[key] === "string") return entry[key]; + } + } + return null; +} + +function readLedger(personRoot) { + const ledgerPath = join(personRoot, "knowledge", "index.json"); + const bytes = readFileSync(ledgerPath); + const parsed = JSON.parse(bytes.toString("utf8")); + const entries = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.entries) ? parsed.entries : []; + const declared = []; + for (const entry of entries) { + const list = Array.isArray(entry?.anchors) ? entry.anchors : []; + for (const item of list) { + const id = anchorIdOf(item); + if (typeof id === "string" && ANCHOR_PATTERN.test(id)) declared.push(id); + } + } + return { + path: ledgerPath, + relativePath: "knowledge/index.json", + bytes: bytes.length, + sha256: sha256Hex(bytes), + entries, + declared: sortedAnchors(declared), + }; +} + +function listTextFiles(personRoot) { + const textRoot = join(personRoot, "knowledge", "text"); + if (!existsSync(textRoot)) return []; + return readdirSync(textRoot) + .filter((name) => name.endsWith(".md")) + .sort() + .map((name) => { + const path = join(textRoot, name); + const bytes = readFileSync(path); + return { + name, + path, + relativePath: `knowledge/text/${name}`, + bytes: bytes.length, + sha256: sha256Hex(bytes), + text: bytes.toString("utf8"), + }; + }); +} + +/** `2024-03-04T09:02:00Z` → epoch milliseconds. Hand-rolled so it is UTC and + * implementation-independent (no `Date` locale or timezone behaviour). */ +function parseTimestamp(raw) { + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.exec( + raw, + ); + if (!match) return null; + const [, year, month, day, hour = "00", minute = "00", second = "00", zone] = match; + let offsetMinutes = 0; + if (zone && zone !== "Z") { + const sign = zone.startsWith("-") ? -1 : 1; + const digits = zone.slice(1).replace(":", ""); + offsetMinutes = sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4))); + } + const utc = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ); + return utc - offsetMinutes * 60000; +} + +function toIso(milliseconds) { + return new Date(milliseconds).toISOString().replace(".000Z", "Z"); +} + +/** + * Split one normalised text file into raw blocks: every line carrying at least + * one anchor — bracketed anywhere, or bare at the head of the line — starts a + * block, and an anchor-free non-empty line continues the previous one. + */ +function readBlocks(text) { + const blocks = []; + for (const line of text.split(/\r?\n/)) { + const anchors = [...line.matchAll(ANCHOR_BRACKETS)].map((match) => match[1]); + if (anchors.length === 0) { + const leading = ANCHOR_LEADING.exec(line); + if (leading) anchors.push(leading[1]); + } + if (anchors.length > 0) { + blocks.push({ anchors, line }); + } else if (line.trim() !== "" && blocks.length > 0) { + blocks[blocks.length - 1].line += ` ${line.trim()}`; + } + } + return blocks; +} + +/** + * Strip anchors, the optional inline timestamp and the optional `speaker:` + * prefix from a block, returning the three parts. + */ +function splitBlock(line) { + let rest = line.replace(ANCHOR_BRACKETS, " "); + rest = rest.replace(ANCHOR_LEADING, " "); + let at = null; + const stamp = LEADING_TIMESTAMP.exec(rest); + if (stamp) { + at = parseTimestamp(stamp[1]); + rest = rest.slice(stamp[0].length); + } + // A subtitle timecode sits where a chat timestamp would; consume it before the + // speaker, or the speaker regex eats "00" out of "00:00:01.000 面试官:". + const timecode = LEADING_TIMECODE.exec(rest); + if (timecode) rest = rest.slice(timecode[0].length); + + let speaker = null; + const prefix = LEADING_SPEAKER.exec(rest); + if (prefix) { + speaker = prefix[1].trim(); + rest = rest.slice(prefix[0].length); + } + return { at, speaker, text: rest.replace(/\s+/g, " ").trim() }; +} + +/** + * Build the citable units. + * + * Each unit carries the anchor that will be written into `evidence`. When the + * ledger declares only the paragraph-level form of a turn-level anchor, the + * turns are merged into one unit and the merge is reported in `notes` — the + * statistics then describe exactly the granularity a reader can go and check. + */ +function buildUnits(blocks, ledger) { + const declared = new Set(ledger.declared); + const notes = []; + const warnings = []; + const byAnchor = new Map(); + const order = []; + const undeclared = []; + let merges = 0; + + const emittableOf = (anchor) => { + if (declared.has(anchor)) return anchor; + const base = anchor.split(":")[0]; + if (declared.has(base)) return base; + return null; + }; + + for (const block of blocks) { + const parts = splitBlock(block.line); + const targets = []; + for (const anchor of block.anchors) { + const emittable = emittableOf(anchor); + if (emittable === null) { + undeclared.push(anchor); + continue; + } + if (emittable !== anchor) merges += 1; + if (!targets.includes(emittable)) targets.push(emittable); + } + for (const anchor of unique(targets)) { + if (!byAnchor.has(anchor)) { + const unit = { + anchor, + file: block.file, + speakers: [], + texts: [], + ats: [], + }; + byAnchor.set(anchor, unit); + order.push(unit); + } + const unit = byAnchor.get(anchor); + if (parts.speaker !== null) unit.speakers.push(parts.speaker); + if (parts.at !== null) unit.ats.push(parts.at); + unit.texts.push(parts.text); + } + } + + const units = order.map((unit, index) => { + const text = unit.texts.filter(Boolean).join(" "); + const speakers = unique(unit.speakers); + const ats = unit.ats.slice().sort((left, right) => left - right); + return { + index, + anchor: unit.anchor, + file: unit.file, + text, + chars: countChars(text), + speaker: speakers.length === 1 ? speakers[0] : null, + speakers, + at: ats.length > 0 ? ats[0] : null, + atLast: ats.length > 0 ? ats[ats.length - 1] : null, + mergedTurns: unit.texts.length, + }; + }); + + if (merges > 0) { + notes.push( + note( + `账本只声明段落级锚点:${merges} 条更细粒度的消息被合并到可引用锚点上,统计以合并后的 ${units.length} 个单元为单位。`, + `The ledger only declares paragraph anchors, so ${merges} finer-grained messages were merged onto citable anchors; statistics describe the resulting ${units.length} units.`, + ), + ); + } + if (undeclared.length > 0) { + warnings.push( + `${undeclared.length} anchor(s) appear in knowledge/text but are not declared in knowledge/index.json (first: ${undeclared[0]}); they were not cited.`, + ); + } + return { units, notes, warnings }; +} + +function orderUnits(units) { + const timed = units.filter((unit) => unit.at !== null); + if (timed.length === units.length && units.length > 1) { + return units.slice().sort((left, right) => left.at - right.at || left.index - right.index); + } + return units.slice(); +} + +function readCorpus(personRoot) { + const ledger = readLedger(personRoot); + const files = listTextFiles(personRoot); + const warnings = []; + const notes = []; + const blocks = []; + for (const file of files) { + for (const block of readBlocks(file.text)) blocks.push({ ...block, file: file.relativePath }); + // Provenance is established by the ledger's own record of where the text went, + // not by comparing a raw-file digest with a normalised-text digest: those are + // two different files and the comparison can never hold. The raw digest is + // kept as a fallback for entries written by other producers. + const recordedAs = file.relativePath.replace(/^knowledge\//, ""); + const digestKnown = ledger.entries.some( + (entry) => entry?.locations?.text === recordedAs || entry?.sha256 === file.sha256, + ); + if (!digestKnown) { + warnings.push( + `knowledge/${file.relativePath.replace(/^knowledge\//, "")} has no ledger entry with a matching sha256; it was read but not trusted for provenance.`, + ); + } + } + const built = buildUnits(blocks, ledger); + const units = orderUnits(built.units); + const timestamps = units.flatMap((unit) => (unit.at === null ? [] : [unit.at])); + return { + ledger, + files, + units, + timestamps, + notes: [...notes, ...built.notes], + warnings: [...warnings, ...built.warnings], + inputs: [ledger, ...files].map((file) => ({ + path: file.relativePath, + sha256: file.sha256, + bytes: file.bytes, + })), + }; +} + +// --------------------------------------------------------------------------- +// stats — 条数 / 参与者 / 时间跨度 / 消息长度分布 / 单位时间密度 +// --------------------------------------------------------------------------- + +export function deriveStats(corpus) { + const { units, timestamps } = corpus; + const claims = []; + const notes = []; + if (units.length < MIN_UNITS.any) { + notes.push( + note( + `样本不足:只有 ${units.length} 条可引用消息,低于所有维度的最低样本数 ${MIN_UNITS.any},未产出任何结论。`, + `Insufficient sample: only ${units.length} citable messages, below the minimum of ${MIN_UNITS.any} for every dimension, so no claim was produced.`, + ), + ); + return { claims, notes }; + } + + const anchors = units.map((unit) => unit.anchor); + claims.push( + makeClaim( + "stats.message_count", + "可引用消息条数", + "Number of citable messages", + units.length, + spread(anchors, 3), + units.length, + ), + ); + + const files = unique(units.map((unit) => unit.file)); + if (files.length > 1) { + const perFile = files.map((file) => units.find((unit) => unit.file === file).anchor); + claims.push( + makeClaim( + "stats.source_count", + "来源文件数", + "Number of source files", + files.length, + perFile, + units.length, + ), + ); + } + + const speakerCounts = new Map(); + const firstAnchorOfSpeaker = new Map(); + for (const unit of units) { + for (const speaker of unit.speakers) { + speakerCounts.set(speaker, (speakerCounts.get(speaker) ?? 0) + 1); + if (!firstAnchorOfSpeaker.has(speaker)) firstAnchorOfSpeaker.set(speaker, unit.anchor); + } + } + const participants = [...speakerCounts.keys()].sort( + (left, right) => speakerCounts.get(right) - speakerCounts.get(left) || (left < right ? -1 : 1), + ); + if (participants.length >= MIN_UNITS.participants) { + claims.push( + makeClaim( + "stats.participants", + "参与者(按发言条数降序)", + "Participants, most active first", + participants, + participants.map((name) => firstAnchorOfSpeaker.get(name)), + units.length, + ), + ); + claims.push( + makeClaim( + "stats.participant_count", + "参与者人数", + "Number of participants", + participants.length, + participants.map((name) => firstAnchorOfSpeaker.get(name)), + units.length, + ), + ); + } else { + notes.push( + note( + `参与者维度跳过:只识别到 ${participants.length} 个说话人,低于阈值 ${MIN_UNITS.participants}。`, + `Participants skipped: only ${participants.length} speakers recognised, below the threshold of ${MIN_UNITS.participants}.`, + ), + ); + } + + if (timestamps.length >= MIN_UNITS.timeSpan) { + const sorted = timestamps.slice().sort((left, right) => left - right); + const spanMs = sorted[sorted.length - 1] - sorted[0]; + const firstTimed = units.find((unit) => unit.at === sorted[0]); + const lastTimed = units.reduce( + (found, unit) => (unit.at !== null && unit.at >= (found?.at ?? -Infinity) ? unit : found), + null, + ); + const evidence = [firstTimed?.anchor, lastTimed?.anchor].filter(Boolean); + claims.push( + makeClaim( + "stats.time_range", + "时间范围(首条 / 末条时间戳)", + "Time range (first / last timestamp)", + { from: toIso(sorted[0]), to: toIso(sorted[sorted.length - 1]) }, + evidence, + timestamps.length, + ), + ); + claims.push( + makeClaim( + "stats.time_span_days", + "时间跨度(天)", + "Time span in days", + round(spanMs / 86400000, 3), + evidence, + timestamps.length, + ), + ); + const spanHours = spanMs / 3600000; + if (spanHours >= 1) { + claims.push( + makeClaim( + "stats.density_per_hour", + "单位时间密度(条/小时)", + "Message density (messages per hour)", + { + per_hour: round(units.length / spanHours, 4), + span_hours: round(spanHours, 3), + messages: units.length, + }, + spread(anchors, 3), + timestamps.length, + ), + ); + } else { + notes.push( + note( + "单位时间密度跳过:可解析的时间跨度不足 1 小时,密度会退化成无意义的巨大值。", + "Density skipped: the parseable span is under one hour, which would make the ratio meaninglessly large.", + ), + ); + } + } else { + notes.push( + note( + `时间跨度与密度跳过:只有 ${timestamps.length} 条消息带可解析时间戳,低于阈值 ${MIN_UNITS.timeSpan}。`, + `Time span and density skipped: only ${timestamps.length} messages carry a parseable timestamp, below the threshold of ${MIN_UNITS.timeSpan}.`, + ), + ); + } + + if (units.length >= MIN_UNITS.lengths) { + const lengths = units.map((unit) => unit.chars).sort((left, right) => left - right); + const shortest = units.reduce((found, unit) => (unit.chars < found.chars ? unit : found), units[0]); + const longest = units.reduce((found, unit) => (unit.chars > found.chars ? unit : found), units[0]); + claims.push( + makeClaim( + "stats.message_length", + "消息长度分布(字符数)", + "Message length distribution (characters)", + { + unit: "characters", + mean: round(mean(lengths), 2), + median: round(median(lengths), 2), + p90: round(percentile(lengths, 0.9), 2), + min: lengths[0], + max: lengths[lengths.length - 1], + }, + [shortest.anchor, longest.anchor], + units.length, + ), + ); + } + + return { claims, notes }; +} + +// --------------------------------------------------------------------------- +// document assembly +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// additional dimensions +// --------------------------------------------------------------------------- + +/** + * The six dimensions that live in their own module: `voice`, `relations`, + * `timeline`, `boundaries`, `shifts`, `conflicts`. + * + * They are handed this file's private helpers (claim construction, thresholds) + * rather than re-deriving them, so every dimension measures and cites the same + * way `stats` does — and a threshold change stays in one place. + */ +const DIMENSION_HELPERS = { + makeClaim, + note, + MIN: MIN_UNITS, + SHIFT_WINDOW_RATIO, + SHIFT_WINDOW_MIN, + SHIFT_WINDOW_MAX, + SHIFT_RELATIVE, + SHIFT_ABSOLUTE, +}; + +// 不叫 run:本文件已经导出了一个同名的 CLI 入口函数。 +// +// 低于全局下限时统一短路:语料太薄,**每个**维度都无话可说,此时报各自的 +// 分维度阈值("少于 10 句")虽然不假却会误导 —— 读者需要的是那个解释"为什么 +// 一条结论都没有"的数字。 +const withHelpers = (deriver) => (corpus) => { + const floor = MIN_UNITS.any; + if (corpus.units.length < floor) { + return { + claims: [], + notes: [ + // 拼接而不是模板字符串:这段代码本身住在一个模板字符串里, + // 写成插值会被**补丁文件**在写入时求值(那时 corpus 还不存在)。 + note( + "样本不足:只有 " + corpus.units.length + " 条可引用消息,低于所有维度的最低样本数 " + floor + ",未产出任何结论。", + "Insufficient sample: only " + corpus.units.length + " citable messages, below the minimum of " + floor + " for every dimension, so no claim was produced.", + ), + ], + }; + } + return deriver(corpus, DIMENSION_HELPERS); +}; + +const DERIVERS = { + stats: deriveStats, + voice: withHelpers(deriveVoice), + relations: withHelpers(deriveRelations), + timeline: withHelpers(deriveTimeline), + boundaries: withHelpers(deriveBoundaries), + shifts: withHelpers(deriveShifts), + conflicts: withHelpers(deriveConflicts), +}; + +/** Run every available dimension and assemble the seven documents. */ +export function deriveDocuments(corpus) { + const generatedFrom = corpus.inputs.map((input) => ({ + path: input.path, + sha256: input.sha256, + })); + const documents = {}; + const notes = []; + for (const kind of DERIVED_KINDS) { + const deriver = DERIVERS[kind]; + const result = deriver ? deriver(corpus) : { claims: [], notes: [] }; + documents[kind] = { + kind, + generated_from: generatedFrom, + claims: result.claims.filter(Boolean), + notes: unique([...corpus.notes, ...result.notes]), + }; + for (const line of result.notes) notes.push({ kind, line }); + } + return { documents, notes }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +const HELP = `distilly retrospect — 纯派生:knowledge/ → evidence/derived/*.json + +用法 / Usage: + distilly retrospect --person [--json] + distilly retrospect --dir [--json] + distilly retrospect --help + +选项 / Options: + --person 在 ./skills/*// 下查找该人的目录 + --dir 直接指定人的目录(含 knowledge/index.json) + --json 只打印机器可读回执 / print the machine-readable receipt only + --help 打印本帮助 / print this help + +输入(只读) / Inputs (read only): + knowledge/index.json 账本;每条结论的锚点都必须在这里声明 + knowledge/text/*.md 归一化正文,段落锚点 [k0012] / [k0012:t3] + +输出 / Outputs: + evidence/derived/{stats,voice,relations,timeline,boundaries,shifts,conflicts}.json + +退出码 / Exit codes: + 0 成功 2 缺输入或用法错误(回执里给出补救步骤) + +不联网、不调用任何模型:同一份输入跑两次,产物逐字节相同。 +No network and no model call: the same input produces byte-identical output. +`; + +function parseArgs(args) { + const options = { person: null, dir: null, json: false, help: false }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--json") options.json = true; + else if (arg === "--person" || arg === "--dir") { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + return { error: `${arg} requires a value` }; + } + options[arg === "--person" ? "person" : "dir"] = value; + index += 1; + } else return { error: `unknown option: ${arg}` }; + } + return { options }; +} + +function familyDirs(skillsRoot) { + if (!existsSync(skillsRoot)) return []; + return readdirSync(skillsRoot) + .sort() + .map((name) => join(skillsRoot, name)) + .filter((path) => { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } + }); +} + +/** Resolve the person directory from `--dir`, `--person` or the cwd. */ +export function resolvePersonRoot(options, cwd) { + const hasLedger = (path) => existsSync(join(path, "knowledge", "index.json")); + if (options.dir) { + const path = resolve(cwd, options.dir); + return hasLedger(path) ? { root: path } : { error: `${path} has no knowledge/index.json` }; + } + if (options.person) { + const candidates = [ + join(cwd, "skills", "colleague", options.person), + ...familyDirs(join(cwd, "skills")).map((family) => join(family, options.person)), + ]; + for (const candidate of unique(candidates)) { + if (hasLedger(candidate)) return { root: candidate }; + } + return { + error: `${join(cwd, "skills", "*", options.person)} has no knowledge/index.json`, + }; + } + if (hasLedger(cwd)) return { root: cwd }; + return { error: `${cwd} has no knowledge/index.json` }; +} + +function writeDerivedFiles(personRoot, documents) { + const outputDir = join(personRoot, "evidence", "derived"); + mkdirSync(outputDir, { recursive: true }); + const outputs = []; + for (const kind of DERIVED_KINDS) { + const body = Buffer.from(`${stableStringify(documents[kind])}\n`, "utf8"); + const path = join(outputDir, `${kind}.json`); + const staging = join(outputDir, `.${basename(path)}.${process.pid}.tmp`); + writeFileSync(staging, body); + renameSync(staging, path); + outputs.push({ + path: `evidence/derived/${kind}.json`, + sha256: sha256Hex(body), + bytes: body.length, + }); + } + return outputs; +} + +function humanSummary(receipt, documents) { + const lines = [`retrospect: ${receipt.outputs.length} 个派生文件 / derived files`]; + for (const output of receipt.outputs) { + const size = documents[basename(output.path, ".json")].claims.length; + lines.push(` ${output.path} ${size} claim(s) ${output.sha256.slice(0, 12)}`); + } + if (receipt.warnings.length > 0) { + lines.push(` 警告 / warnings: ${receipt.warnings.length}`); + for (const warning of receipt.warnings) lines.push(` - ${warning}`); + } + return `${lines.join("\n")}\n`; +} + +/** + * CLI entry point. `io` may override the working directory and the two streams, + * which is how the tests drive it without spawning a process. + * + * @returns {{exitCode: number, receipt: object}} + */ +export function run(args = [], io = {}) { + const cwd = io.cwd ?? process.cwd(); + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + const parsed = parseArgs(args); + + const emit = (receipt, exitCode, documents = null) => { + if (parsed.options?.json) stdout.write(`${stableStringify(receipt)}\n`); + else if (exitCode !== 0) stderr.write(`retrospect: ${receipt.error.message}\n`); + else stdout.write(humanSummary(receipt, documents)); + return { exitCode, receipt }; + }; + + if (parsed.error) { + return emit( + { + command: "retrospect", + person: null, + ok: false, + inputs: [], + outputs: [], + anchors: { total: 0, cited: 0 }, + warnings: [], + unavailable: [], + error: { code: "retrospect/usage", message: parsed.error, remedy: "distilly retrospect --help" }, + }, + 2, + ); + } + if (parsed.options.help) { + stdout.write(HELP); + return { exitCode: 0, receipt: null }; + } + + const resolved = resolvePersonRoot(parsed.options, cwd); + if (resolved.error) { + return emit( + { + command: "retrospect", + person: parsed.options.person, + ok: false, + inputs: [], + outputs: [], + anchors: { total: 0, cited: 0 }, + warnings: [], + unavailable: [], + error: { + code: "retrospect/missing-input", + message: resolved.error, + remedy: + "run `distilly harvest --person ` first, then pass the same --person (or --dir )", + }, + }, + 2, + ); + } + + const corpus = readCorpus(resolved.root); + const { documents } = deriveDocuments(corpus); + const outputs = writeDerivedFiles(resolved.root, documents); + + const cited = sortedAnchors( + Object.values(documents).flatMap((document) => + document.claims.flatMap((claim) => claim.evidence), + ), + ); + const ledgerWarnings = corpus.ledger.entries.flatMap((entry) => + (Array.isArray(entry?.warnings) ? entry.warnings : []).map( + (warning) => `ledger:${entry?.id ?? "?"}: ${warning}`, + ), + ); + + const receipt = { + command: "retrospect", + person: parsed.options.person ?? basename(resolved.root), + ok: true, + inputs: corpus.inputs, + outputs, + anchors: { total: corpus.ledger.declared.length, cited: cited.length }, + warnings: [...corpus.warnings, ...ledgerWarnings], + unavailable: [], + }; + return emit(receipt, 0, documents); +} + +export default run; From d076e4ce2c06b0d8847b4fd60f46c9061ece6f31 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 2/7] feat(cli): register view check and view render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:feat(cli): register view check and view render --- scripts/visual-check.mjs | 541 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 541 insertions(+) create mode 100644 scripts/visual-check.mjs diff --git a/scripts/visual-check.mjs b/scripts/visual-check.mjs new file mode 100644 index 00000000..095b26d7 --- /dev/null +++ b/scripts/visual-check.mjs @@ -0,0 +1,541 @@ +#!/usr/bin/env node +/** + * Is this anchor's outcome a problem? + * + * Every appendix row must exist, be focusable and be visible; only an anchor the + * prose cites must also carry a back-link. Pure so it can be unit-tested without a + * browser. + */ +export function anchorProblem(outcome, cited) { + if (!outcome || outcome.ok !== true) return true; + if (outcome.inAppendix !== true) return true; + if (outcome.focused !== true) return true; + if (outcome.visible !== true) return true; + if (cited === true && !(outcome.backLinks >= 1)) return true; + return false; +} + +/** + * distilly visual-check — open a rendered view page in Chrome and assert the + * eight visual contracts from docs/v2/CONTRACT.md §4: + * + * 1 console is silent (no error/warning, no pageerror, no failed request) + * 2 the eight page segments exist and are non-empty + * 3 no horizontal overflow (1280 / 768 / 375 px) + * 4 dual-theme contrast spot checks (system preference + manual toggle) + * 5 every evidence anchor resolves to a focusable row in the appendix + * 6 zero network requests, CSP present, no external reference + * 7 @media print does not clip or drop content + * 8 PNG evidence is written to --out + * + * playwright is a DEVELOPMENT dependency and is never imported by the runtime: + * when it is missing this script fails loudly with install guidance. + * + * node scripts/visual-check.mjs views/.html [--out ] [--json] + */ +import { isEntryPoint } from "../src/cli/entry.mjs"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); +const DEFAULT_OUT = "/tmp/dst-evidence/pr-03"; +const RESULTS = []; + +const SAMPLE_SELECTORS = [ + "#page-title", + ".claim__text", + ".claim__meta", + ".anchor-ref", + ".badge", + ".warning__text", + ".evidence__anchor", +]; + +function parseArgs(argv) { + const options = { html: null, out: DEFAULT_OUT, json: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--json") options.json = true; + else if (arg === "--out" || arg === "--out-dir") { + const value = argv[index + 1]; + if (!value) throw new Error(`${arg} requires a directory`); + options.out = value; + index += 1; + } else if (arg === "--help" || arg === "-h") options.help = true; + else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`); + else if (!options.html) options.html = arg; + else throw new Error(`unexpected argument: ${arg}`); + } + return options; +} + +function usage() { + console.log(`Usage: node scripts/visual-check.mjs [--out ] [--json] + + a page produced by: distilly view render + --out PNG output directory (default ${DEFAULT_OUT}; never committed) + --json print the machine-readable result + +Exit code 0 only when all eight checks pass.`); +} + +/** playwright is a dev dependency: resolve it from the usual places, else fail loudly. */ +async function loadChromium() { + const roots = [process.env.DISTILLY_PLAYWRIGHT_ROOT, ROOT, process.cwd()].filter(Boolean); + for (const root of roots) { + try { + const require = createRequire(join(root, "index.cjs")); + const resolved = require.resolve("playwright"); + const mod = await import(pathToFileURL(resolved).href); + const chromium = mod.chromium ?? mod.default?.chromium; + if (chromium) return chromium; + } catch (error) { + /* try the next root */ + } + } + try { + const mod = await import("playwright"); + const chromium = mod.chromium ?? mod.default?.chromium; + if (chromium) return chromium; + } catch (error) { + /* fall through to the loud failure below */ + } + console.error("Error: the visual check needs playwright, which is a development dependency."); + console.error(" npm install --no-save playwright # or: pnpm add -D playwright"); + console.error(" DISTILLY_PLAYWRIGHT_ROOT= node scripts/visual-check.mjs "); + console.error(" distilly itself has zero runtime dependencies; nothing else needs playwright."); + process.exit(2); +} + +async function launch(chromium) { + try { + return await chromium.launch({ channel: "chrome" }); + } catch (error) { + return chromium.launch(); + } +} + +function record(id, name, ok, detail) { + RESULTS.push({ id, name, ok: Boolean(ok), detail }); + return Boolean(ok); +} + +/** Contrast of a node against its nearest opaque ancestor background, WCAG 2.x ratio. */ +function contrastProbe(selectors) { + const parse = (value) => { + const match = /rgba?\(([^)]+)\)/.exec(value || ""); + if (!match) return null; + const parts = match[1].split(/[\s,/]+/).filter(Boolean).map(Number); + return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }; + }; + const luminance = ({ r, g, b }) => { + const channel = (value) => { + const scaled = value / 255; + return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + }; + const background = (node) => { + let current = node; + while (current && current.nodeType === 1) { + const colour = parse(getComputedStyle(current).backgroundColor); + if (colour && colour.a > 0.5) return colour; + current = current.parentElement; + } + return { r: 255, g: 255, b: 255, a: 1 }; + }; + const ratio = (a, b) => { + const first = luminance(a); + const second = luminance(b); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); + }; + + const samples = []; + for (const selector of selectors) { + const node = document.querySelector(selector); + if (!node) { + samples.push({ selector, missing: true }); + continue; + } + const style = getComputedStyle(node); + const foreground = parse(style.color); + const behind = background(node); + samples.push({ + selector, + fontSize: Number.parseFloat(style.fontSize), + ratio: foreground ? Number(ratio(foreground, behind).toFixed(2)) : null, + foreground: style.color, + background: `rgb(${behind.r}, ${behind.g}, ${behind.b})`, + }); + } + return { theme: document.documentElement.getAttribute("data-theme-effective"), samples }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + usage(); + return 0; + } + if (!options.html) { + usage(); + return 2; + } + const htmlPath = resolve(options.html); + if (!existsSync(htmlPath)) { + console.error(`Error: rendered page not found: ${htmlPath}`); + console.error(" fix: distilly view render (or pass the path of an existing views/.html)"); + return 2; + } + const outDir = resolve(options.out); + mkdirSync(outDir, { recursive: true }); + const url = pathToFileURL(htmlPath).href; + const ready = () => page.waitForFunction(() => document.documentElement.getAttribute("data-view-ready") === "true", null, { timeout: 15000 }); + + const chromium = await loadChromium(); + const browser = await launch(chromium); + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + deviceScaleFactor: 1, + colorScheme: "light", + }); + const page = await context.newPage(); + + const consoleMessages = []; + const pageErrors = []; + const failedRequests = []; + const requests = []; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + consoleMessages.push({ type: message.type(), text: message.text() }); + } + }); + page.on("pageerror", (error) => pageErrors.push(String(error && error.message ? error.message : error))); + page.on("requestfailed", (request) => failedRequests.push({ url: request.url(), error: request.failure()?.errorText ?? null })); + page.on("request", (request) => requests.push({ url: request.url(), type: request.resourceType() })); + + const pngs = []; + const screenshot = async (name) => { + const file = join(outDir, name); + await page.screenshot({ path: file, fullPage: true }); + pngs.push({ file, bytes: statSync(file).size }); + }; + + try { + await page.goto(url, { waitUntil: "load" }); + await ready(); + + /* 1 — console silence ------------------------------------------------ */ + record( + "console", + "console has no error/warning, no page error, no failed request", + consoleMessages.length === 0 && pageErrors.length === 0 && failedRequests.length === 0, + { messages: consoleMessages, pageErrors, failedRequests }, + ); + + /* 2 — eight non-empty segments --------------------------------------- */ + const segments = await page.evaluate(() => { + const rows = [...document.querySelectorAll("[data-section]")].map((node) => ({ + id: node.getAttribute("data-section"), + chars: (node.textContent || "").trim().length, + items: node.querySelectorAll(".claim, .warning, .timeline__item, .evidence").length, + })); + const view = window.DistillyView || {}; + return { + rows, + payloadAnchors: view.view && Array.isArray(view.view.evidence) ? view.view.evidence.length : 0, + appendixAnchors: document.querySelectorAll('[data-section="evidence"] .evidence[data-anchor]').length, + shareable: Boolean(view.shareable), + quotesRendered: document.querySelectorAll(".quote[data-inlined]").length, + }; + }); + const emptySegments = segments.rows.filter((entry) => entry.chars < 8); + record( + "segments", + "the eight page segments exist and are non-empty", + segments.rows.length === 8 && emptySegments.length === 0 && segments.appendixAnchors > 0, + { + count: segments.rows.length, + empty: emptySegments.map((entry) => entry.id), + rows: segments.rows, + shareable: segments.shareable, + quotesRendered: segments.quotesRendered, + }, + ); + + /* 3 — no horizontal overflow ---------------------------------------- */ + const overflow = []; + for (const width of [1280, 768, 375]) { + await page.setViewportSize({ width, height: 900 }); + const measured = await page.evaluate(() => { + const limit = window.innerWidth + 1; + const offenders = []; + for (const node of document.querySelectorAll("body *")) { + const rect = node.getBoundingClientRect(); + if (rect.width > 0 && rect.right > limit) { + offenders.push({ + tag: node.tagName.toLowerCase(), + cls: String(node.className || "").slice(0, 60), + right: Math.round(rect.right), + }); + } + } + return { delta: document.documentElement.scrollWidth - window.innerWidth, offenders: offenders.slice(0, 5) }; + }); + overflow.push({ width, delta: measured.delta, offenders: measured.offenders }); + } + await page.setViewportSize({ width: 1280, height: 900 }); + record( + "overflow", + "no horizontal overflow at 1280/768/375 px", + overflow.every((entry) => entry.delta <= 1), + overflow, + ); + + /* 4 — dual theme contrast ------------------------------------------- */ + const themeRuns = []; + // `emulateMedia` resolves as soon as the emulation is applied; the page learns + // about it through a `matchMedia` change event and updates + // `data-theme-effective` a tick later. Probing immediately recorded the + // *previous* theme — which is how this gate went red roughly one run in two + // with byte-identical HTML (same sha256), and why the toggle then saw + // `before: "dark"` after being put back into light mode. + const settle = (scheme) => + page + .waitForFunction( + (expected) => { + const current = document.documentElement.getAttribute("data-theme-effective"); + return current === null || current === expected; + }, + scheme, + { timeout: 2000 }, + ) + .catch(() => {}); + await page.emulateMedia({ colorScheme: "light" }); + await settle("light"); + themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS)); + await page.emulateMedia({ colorScheme: "dark" }); + await settle("dark"); + themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS)); + await page.emulateMedia({ colorScheme: "light" }); + await settle("light"); + const toggle = await page.evaluate(() => { + const button = document.getElementById("theme-toggle"); + if (!button) return { ok: false, reason: "no #theme-toggle button" }; + const before = document.documentElement.getAttribute("data-theme-effective"); + button.click(); + return { ok: null, before, after: null, pressed: button.getAttribute("aria-pressed"), label: button.textContent }; + }); + // The click is handled by the page, so its effect is also a tick away. + toggle.after = await page + .waitForFunction( + (before) => document.documentElement.getAttribute("data-theme-effective") !== before, + toggle.before, + { timeout: 2000 }, + ) + .then(() => page.evaluate(() => document.documentElement.getAttribute("data-theme-effective"))) + .catch(() => page.evaluate(() => document.documentElement.getAttribute("data-theme-effective"))); + toggle.pressed = await page.evaluate(() => document.getElementById("theme-toggle")?.getAttribute("aria-pressed") ?? null); + toggle.ok = toggle.before === "light" && toggle.after === "dark"; + themeRuns.push(await page.evaluate(contrastProbe, SAMPLE_SELECTORS)); + const contrastFailures = []; + for (const run of themeRuns) { + for (const sample of run.samples) { + if (sample.missing) contrastFailures.push({ ...sample, theme: run.theme, reason: "sample element missing" }); + else if (sample.ratio !== null && sample.ratio < 4.5 && sample.fontSize < 24) { + contrastFailures.push({ ...sample, theme: run.theme, reason: "contrast below 4.5:1" }); + } + } + } + record( + "theme", + "dual theme (system + manual) with >= 4.5:1 contrast samples", + contrastFailures.length === 0 && toggle.ok === true && new Set(themeRuns.map((run) => run.theme)).size >= 2, + { runs: themeRuns, toggle, failures: contrastFailures }, + ); + await page.emulateMedia({ colorScheme: "light" }); + await page.evaluate(() => { + const button = document.getElementById("theme-toggle"); + if (button && document.documentElement.getAttribute("data-theme-effective") === "dark") button.click(); + }); + + /* 5 — anchors resolve into the appendix ------------------------------ */ + const anchorIds = await page.evaluate(() => + [...document.querySelectorAll('[data-section="evidence"] .evidence[data-anchor]')].map((node) => node.getAttribute("data-anchor")), + ); + const anchorProblems = []; + for (const anchor of anchorIds) { + await page.evaluate((id) => { + window.location.hash = `#anchor-${id}`; + }, anchor); + await page.waitForTimeout(40); + const outcome = await page.evaluate((id) => { + const node = document.getElementById(`anchor-${id}`); + if (!node) return { id, ok: false, reason: "no element with that id" }; + const rect = node.getBoundingClientRect(); + return { + id, + ok: true, + inAppendix: Boolean(node.closest('[data-section="evidence"]')), + focused: document.activeElement === node, + visible: rect.top < window.innerHeight && rect.bottom > 0, + backLinks: node.querySelectorAll('a[href^="#section-"]').length, + }; + }, anchor); + if (!outcome.ok || !outcome.inAppendix || !outcome.focused || !outcome.visible || outcome.backLinks === 0) { + anchorProblems.push(outcome); + } + } + await page.evaluate(() => { + try { + window.history.replaceState(null, "", window.location.pathname); + } catch (error) { + window.location.hash = ""; + } + }); + record( + "anchors", + "each evidence anchor locates a focusable row in the appendix", + anchorIds.length > 0 && anchorIds.length === segments.payloadAnchors && anchorProblems.length === 0, + { anchors: anchorIds.length, payloadAnchors: segments.payloadAnchors, problems: anchorProblems }, + ); + + /* 6 — zero network requests ----------------------------------------- */ + const staticRefs = await page.evaluate(() => { + const csp = document.querySelector('meta[http-equiv="Content-Security-Policy"]'); + return { + csp: csp ? csp.getAttribute("content") : null, + externalLinks: document.querySelectorAll('link[href]:not([href^="data:"])').length, + externalScripts: document.querySelectorAll("script[src]").length, + externalImages: document.querySelectorAll('img[src]:not([src^="data:"])').length, + embeds: document.querySelectorAll("iframe, object, embed").length, + urls: (document.documentElement.outerHTML.match(/https?:\/\/[^\s"'<>]+/g) || []).filter( + (value) => !value.includes("www.w3.org"), + ), + }; + }); + const externalRequests = requests.filter( + (entry) => !entry.url.startsWith("file:") && !entry.url.startsWith("data:") && !entry.url.startsWith("blob:"), + ); + record( + "offline", + "zero network requests, frozen CSP present, no external reference", + externalRequests.length === 0 && + Boolean(staticRefs.csp && staticRefs.csp.includes("default-src 'none'")) && + staticRefs.externalLinks === 0 && + staticRefs.externalScripts === 0 && + staticRefs.externalImages === 0 && + staticRefs.embeds === 0 && + staticRefs.urls.length === 0, + { requests: requests.length, externalRequests, staticRefs }, + ); + + /* 7 — print media does not clip -------------------------------------- */ + await page.emulateMedia({ media: "print" }); + const printReport = await page.evaluate(() => { + const nodes = [...document.querySelectorAll("[data-section]")]; + const clipped = []; + let sectionText = 0; + for (const node of nodes) { + const style = getComputedStyle(node); + sectionText += (node.textContent || "").length; + if (style.display === "none" || style.visibility === "hidden") { + clipped.push({ id: node.getAttribute("data-section"), reason: "hidden in print" }); + continue; + } + if (node.scrollWidth > node.clientWidth + 2) { + clipped.push({ id: node.getAttribute("data-section"), reason: "horizontal clip", scrollWidth: node.scrollWidth, clientWidth: node.clientWidth }); + } + if (node.scrollHeight > node.clientHeight + 2) { + clipped.push({ id: node.getAttribute("data-section"), reason: "vertical clip", scrollHeight: node.scrollHeight, clientHeight: node.clientHeight }); + } + } + return { segments: nodes.length, clipped, sectionText, overflow: document.documentElement.scrollWidth - window.innerWidth }; + }); + await screenshot("view-print.png"); + await page.emulateMedia({ media: "screen" }); + const screenReport = await page.evaluate(() => { + const nodes = [...document.querySelectorAll("[data-section]")]; + let sectionText = 0; + for (const node of nodes) sectionText += (node.textContent || "").length; + return { sectionText, segments: nodes.length }; + }); + record( + "print", + "@media print does not clip or drop content", + printReport.clipped.length === 0 && + printReport.segments === 8 && + printReport.overflow <= 1 && + printReport.sectionText === screenReport.sectionText, + { ...printReport, screenSectionText: screenReport.sectionText, screenSegments: screenReport.segments }, + ); + + /* 8 — PNG evidence --------------------------------------------------- */ + await page.goto(`${url}?theme=light`, { waitUntil: "load" }); + await ready(); + await page.setViewportSize({ width: 1280, height: 900 }); + await screenshot("view-light.png"); + await page.goto(`${url}?theme=dark`, { waitUntil: "load" }); + await ready(); + await screenshot("view-dark.png"); + await page.setViewportSize({ width: 375, height: 900 }); + await screenshot("view-mobile-375.png"); + record( + "png", + "PNG evidence written to the output directory", + pngs.length >= 4 && pngs.every((entry) => entry.bytes > 1024), + { outDir, pngs }, + ); + + const failed = RESULTS.filter((entry) => !entry.ok); + const payload = { + command: "visual-check", + ok: failed.length === 0, + html: htmlPath, + html_sha256: createHash("sha256").update(readFileSync(htmlPath)).digest("hex"), + html_bytes: statSync(htmlPath).size, + out_dir: outDir, + shareable: segments.shareable, + appendix_anchors: segments.appendixAnchors, + checks: RESULTS, + pngs, + failed: failed.map((entry) => entry.id), + checks_passed: RESULTS.length - failed.length, + checks_total: RESULTS.length, + }; + + if (options.json) console.log(JSON.stringify(payload, null, 2)); + else { + for (const entry of RESULTS) { + console.log(`${entry.ok ? "PASS" : "FAIL"} ${entry.id.padEnd(9)} ${entry.name}`); + if (!entry.ok) console.log(` detail: ${JSON.stringify(entry.detail)}`); + } + console.log(` input: ${htmlPath} (${payload.html_bytes} bytes, sha256 ${payload.html_sha256})`); + } + console.log( + failed.length === 0 + ? `visual-check: PASS — ${RESULTS.length}/8 checks, PNGs in ${outDir} (${pngs.map((entry) => entry.file.split("/").pop()).join(", ")})` + : `visual-check: FAIL — ${failed.length}/${RESULTS.length} checks failed: ${failed.map((entry) => entry.id).join(", ")}`, + ); + return failed.length === 0 ? 0 : 1; + } finally { + await context.close(); + await browser.close(); + } +} + +// Only run when invoked directly. `tests/visual-check-rule.test.mjs` imports this +// module for its checks, and an unguarded `main()` launched a browser and printed +// the usage text during that import — which made the whole test *file* fail rather +// than any single assertion in it. +if (isEntryPoint(import.meta.url)) { + try { + process.exitCode = await main(); + } catch (error) { + console.error(`Error: ${error && error.message ? error.message : error}`); + process.exitCode = 1; + } +} From 63ce9ef46187a6571b26e38392ad47258da45f51 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 3/7] test(derive): assert anchor resolution, determinism and the empty path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:test(derive): assert anchor resolution, determinism and the empty path --- tests/retrospect.test.mjs | 449 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 tests/retrospect.test.mjs diff --git a/tests/retrospect.test.mjs b/tests/retrospect.test.mjs new file mode 100644 index 00000000..bcc9b8df --- /dev/null +++ b/tests/retrospect.test.mjs @@ -0,0 +1,449 @@ +/** + * `retrospect` — deterministic derivation. + * + * The fixture under `src/derive/fixtures/synthetic-group` is a hand-written + * ledger whose features are known in advance (see its README), so every + * assertion here is a claim about behaviour rather than a snapshot of output. + * + * Covered: + * 1. the fixture itself is intact (ledger digests == text bytes) + * 2. seven files, every claim citing an anchor that the ledger declares + * 3. two runs in a fresh directory are byte-identical + * 4. the features the fixture was built to contain are actually found + * 5. two messages produce empty claim lists plus a reason, never a guess + * 6. the module contains no network or model call + * 7. the CLI entry point honours the receipt and exit-code contract + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { DERIVED_KINDS, run, stableStringify } from "../src/derive/retrospect.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); +const FIXTURE = join(repoRoot, "src", "derive", "fixtures", "synthetic-group"); +const CLI = join(repoRoot, "bin", "distilly.mjs"); +const SLUG = "synthetic-group"; +const CONTRACT_ANCHOR = /k\d{4}(?::t\d+)?/g; + +const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex"); + +/** A throwaway working directory holding `skills/colleague//`. */ +function workspace() { + const root = mkdtempSync(join(tmpdir(), "distilly-retrospect-")); + const person = join(root, "skills", "colleague", SLUG); + cpSync(FIXTURE, person, { recursive: true }); + return { root, person }; +} + +/** Run the command in-process, capturing both streams instead of printing. */ +function runCaptured(args, cwd) { + const captured = { out: "", err: "" }; + const result = run(args, { + cwd, + stdout: { write: (chunk) => (captured.out += chunk) }, + stderr: { write: (chunk) => (captured.err += chunk) }, + }); + return { ...result, ...captured }; +} + +function derivedDir(person) { + return join(person, "evidence", "derived"); +} + +function readDerived(person) { + const documents = {}; + for (const name of readdirSync(derivedDir(person)).sort()) { + if (!name.endsWith(".json")) continue; + documents[name.replace(/\.json$/, "")] = JSON.parse( + readFileSync(join(derivedDir(person), name), "utf8"), + ); + } + return documents; +} + +function hashDerived(person) { + const hashes = {}; + for (const name of readdirSync(derivedDir(person)).sort()) { + hashes[name] = sha256(readFileSync(join(derivedDir(person), name))); + } + return hashes; +} + +function ledgerAnchors(person) { + const ledger = JSON.parse(readFileSync(join(person, "knowledge", "index.json"), "utf8")); + return { + ledger, + declared: new Set(ledger.flatMap((entry) => entry.anchors ?? [])), + }; +} + +/** Every anchor physically present in the normalised text. */ +function textAnchors(person) { + const textRoot = join(person, "knowledge", "text"); + const found = new Set(); + for (const name of readdirSync(textRoot).sort()) { + const body = readFileSync(join(textRoot, name), "utf8"); + for (const match of body.matchAll(CONTRACT_ANCHOR)) found.add(match[0]); + } + return found; +} + +test("the fixture ledger digests match the fixture text bytes", () => { + const { ledger, declared } = ledgerAnchors(FIXTURE); + assert.equal(ledger.length, 3); + const textRoot = join(FIXTURE, "knowledge", "text"); + // Pairing is by digest, not by name: a ledger entry's `origin` names the raw + // payload, which is not the same file as the normalised text. + for (const name of readdirSync(textRoot).sort()) { + const bytes = readFileSync(join(textRoot, name)); + const digest = sha256(bytes); + const entry = ledger.find((candidate) => candidate.sha256 === digest); + assert.ok(entry, `${name} has no ledger entry with a matching sha256`); + assert.equal(entry.bytes, bytes.length, `${entry.id} byte count`); + } + assert.equal(declared.size, 71, "the fixture declares 71 anchors"); + for (const anchor of declared) { + assert.match(anchor, /^k\d{4}(?::t\d+)?$/); + } +}); + +test("seven files are written and every claim cites a resolvable anchor", () => { + const { root, person } = workspace(); + try { + const { exitCode, receipt } = runCaptured(["--person", SLUG, "--json"], root); + assert.equal(exitCode, 0); + assert.equal(receipt.ok, true); + + const names = readdirSync(derivedDir(person)).sort(); + assert.deepEqual( + names, + DERIVED_KINDS.map((kind) => `${kind}.json`).sort(), + ); + + const { declared } = ledgerAnchors(person); + const inText = textAnchors(person); + const documents = readDerived(person); + const generatedFrom = documents.stats.generated_from.map((input) => input.path); + assert.deepEqual(generatedFrom, [ + "knowledge/index.json", + "knowledge/text/dm-lin-chen.md", + "knowledge/text/group-chat.md", + "knowledge/text/incident-postmortem.md", + ]); + + for (const kind of DERIVED_KINDS) { + const document = documents[kind]; + assert.equal(document.kind, kind); + assert.ok( + document.claims.length > 0, + `${kind} produced no claim on a 71-message fixture`, + ); + assert.deepEqual( + document.generated_from.map((input) => input.path).sort(), + generatedFrom.slice().sort(), + `${kind}.generated_from must list every input`, + ); + for (const input of document.generated_from) { + assert.match(input.sha256, /^[0-9a-f]{64}$/); + } + + for (const claim of document.claims) { + assert.match(claim.id, new RegExp(`^${kind}\\.`), `${claim.id} is namespaced by file`); + assert.equal(typeof claim.label.zh, "string"); + assert.equal(typeof claim.label.en, "string"); + assert.ok(["high", "medium", "low"].includes(claim.confidence)); + assert.ok( + Array.isArray(claim.evidence) && claim.evidence.length >= 1, + `${claim.id} has no evidence`, + ); + for (const anchor of claim.evidence) { + assert.ok(declared.has(anchor), `${claim.id} cites undeclared anchor ${anchor}`); + assert.ok(inText.has(anchor), `${claim.id} cites anchor ${anchor} absent from text`); + } + assert.equal(new Set(claim.evidence).size, claim.evidence.length, "no duplicate anchors"); + } + } + + // The mechanical assertion from docs/v2/ACCEPTANCE.md §5, run the way the + // acceptance script runs it: by scanning the bytes on disk. + let dangling = 0; + for (const name of names) { + const body = readFileSync(join(derivedDir(person), name), "utf8"); + for (const match of body.matchAll(/"?(k\d{4}(?::t\d+)?)"?/g)) { + if (!declared.has(match[1])) dangling += 1; + } + } + assert.equal(dangling, 0, "no anchor in the derived files may be dangling"); + + assert.ok(receipt.outputs.length === DERIVED_KINDS.length); + for (const output of receipt.outputs) { + assert.match(output.sha256, /^[0-9a-f]{64}$/); + assert.ok(output.bytes > 0); + } + assert.deepEqual(receipt.anchors, { total: 71, cited: receipt.anchors.cited }); + assert.ok(receipt.anchors.cited > 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("two runs over the same ledger are byte-identical", () => { + const first = workspace(); + const second = workspace(); + try { + assert.equal(runCaptured(["--person", SLUG, "--json"], first.root).exitCode, 0); + const before = hashDerived(first.person); + assert.equal(runCaptured(["--person", SLUG, "--json"], first.root).exitCode, 0); + const after = hashDerived(first.person); + assert.deepEqual(after, before, "a second run in the same directory changed the bytes"); + + assert.equal(runCaptured(["--person", SLUG, "--json"], second.root).exitCode, 0); + const elsewhere = hashDerived(second.person); + assert.deepEqual(elsewhere, before, "the same ledger in another directory hashed differently"); + + // The gate `scripts/acceptance.mjs` applies: two separate processes, whose + // pids, clocks and environments all differ. In-process runs cannot see + // process-level nondeterminism, so this one is spawned for real. + rmSync(derivedDir(second.person), { recursive: true, force: true }); + for (let attempt = 0; attempt < 2; attempt += 1) { + const spawned = spawnSync( + process.execPath, + [CLI, "retrospect", "--person", SLUG, "--json"], + { cwd: second.root, encoding: "utf8" }, + ); + assert.equal(spawned.status, 0, spawned.stderr); + } + assert.deepEqual(hashDerived(second.person), before, "a fresh process produced different bytes"); + + // Nothing time, locale or environment dependent may leak into the output. + const stats = readFileSync(join(first.person, "evidence", "derived", "stats.json"), "utf8"); + assert.equal(stats, `${stableStringify(JSON.parse(stats))}\n`); + } finally { + rmSync(first.root, { recursive: true, force: true }); + rmSync(second.root, { recursive: true, force: true }); + } +}); + +test("the features the fixture was built around are actually found", () => { + const { root, person } = workspace(); + try { + runCaptured(["--person", SLUG, "--json"], root); + const documents = readDerived(person); + + // >= 3 speakers and >= 60 messages, per the fixture's purpose. + const participants = documents.stats.claims.find((claim) => claim.id === "stats.participants"); + assert.ok(participants.value.length >= 3, "expected at least three speakers"); + const count = documents.stats.claims.find((claim) => claim.id === "stats.message_count"); + assert.ok(count.value >= 60, "expected at least sixty messages"); + assert.ok( + documents.stats.claims.some((claim) => claim.id === "stats.time_span_days"), + ); + + // One tone/length jump around the incident and one on the way back. + const shifts = documents.shifts.claims; + assert.ok(shifts.length >= 1, "no shift candidate found"); + assert.ok( + shifts.some((claim) => claim.value.metric === "mean_chars"), + "the fixture's length jump was not detected", + ); + + // The two deflections written into the fixture. + const boundaries = documents.boundaries.claims; + assert.ok(boundaries.length >= 2, "no avoidance candidate found"); + const quoted = boundaries + .filter((claim) => claim.value.rules.some((rule) => rule.startsWith("R1"))) + .map((claim) => claim.evidence.join(",")); + assert.ok( + quoted.some((evidence) => evidence.includes("k0001:t18")), + "the offer deflection at k0001:t18/t19 was not found", + ); + assert.ok( + quoted.some((evidence) => evidence.includes("k0001:t31")), + "the second deflection at k0001:t31/t32 was not found", + ); + + // One contradiction inside a single speaker and one across two speakers, + // on two different stance dimensions. + const conflicts = documents.conflicts.claims; + assert.ok( + conflicts.some((claim) => claim.value.same_speaker === true), + "the intra-speaker contradiction was not found", + ); + assert.ok( + conflicts.some((claim) => claim.value.same_speaker === false), + "the cross-speaker contradiction was not found", + ); + assert.deepEqual( + [...new Set(conflicts.map((claim) => claim.value.dimension))].sort(), + ["certainty", "sentiment"], + ); + + // Address change: 林工 -> 林哥 inside the DM. + const shift = documents.relations.claims.find( + (claim) => claim.id === "relations.address_shift.1", + ); + assert.ok(shift, "the address-term change was not found"); + assert.equal(shift.value.from, "林工"); + assert.equal(shift.value.to, "林哥"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("two messages yield empty claim lists and a stated reason", () => { + const root = mkdtempSync(join(tmpdir(), "distilly-retrospect-tiny-")); + const person = join(root, "skills", "colleague", "tiny"); + try { + mkdirSync(join(person, "knowledge", "text"), { recursive: true }); + const body = Buffer.from( + "[k0001:t1] 2024-03-04T09:02:00Z 甲:你好。\n[k0001:t2] 2024-03-04T09:03:00Z 乙:好。\n", + "utf8", + ); + writeFileSync(join(person, "knowledge", "text", "chat.md"), body); + writeFileSync( + join(person, "knowledge", "index.json"), + `${JSON.stringify( + [ + { + id: "k-src-1", + kind: "messages", + origin: "chat.json", + fetched_at: "2024-03-05T00:00:00Z", + bytes: body.length, + sha256: sha256(body), + credentialed: false, + method: "parse-chat", + warnings: [], + anchors: ["k0001:t1", "k0001:t2"], + }, + ], + null, + 2, + )}\n`, + ); + + const { exitCode, receipt } = runCaptured(["--person", "tiny", "--json"], root); + assert.equal(exitCode, 0, "a thin corpus is not an error"); + assert.equal(receipt.ok, true); + + const documents = readDerived(person); + assert.deepEqual(Object.keys(documents).sort(), DERIVED_KINDS.slice().sort()); + for (const kind of DERIVED_KINDS) { + assert.deepEqual(documents[kind].claims, [], `${kind} invented a claim from two messages`); + assert.ok(documents[kind].notes.length >= 1, `${kind} skipped silently`); + const note = documents[kind].notes.join(" "); + assert.match(note, /样本不足|Insufficient sample/); + assert.match(note, /2 条|2 citable/); + assert.match(note, /8/, "the note must name the threshold that was missed"); + } + assert.equal(receipt.anchors.cited, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the derivation calls no network and no model", () => { + const forbidden = [ + [/fetch\s*\(/, "fetch()"], + [/XMLHttpRequest/, "XMLHttpRequest"], + [/node:https?\b/, "node:http(s) import"], + [/\bhttps?:\/\//i, "a URL literal"], + [/openai|anthropic|deepseek|gemini|ollama/i, "a model provider name"], + [/child_process|execSync|spawnSync/, "process spawning"], + [/Math\.random/, "Math.random"], + [/Date\.now/, "Date.now"], + [/new Date\(\s*\)/, "new Date() with no argument"], + [/\brequire\s*\(/, "CommonJS require"], + ]; + const modules = []; + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => + a.name < b.name ? -1 : 1, + )) { + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith(".mjs")) modules.push(path); + } + }; + walk(join(repoRoot, "src", "derive")); + assert.ok(modules.length >= 1, "no derivation module found to scan"); + + for (const path of modules) { + const source = readFileSync(path, "utf8"); + for (const [pattern, label] of forbidden) { + assert.ok( + !pattern.test(source), + `${relative(repoRoot, path)} must not contain ${label}`, + ); + } + // The only imports allowed are Node's own pure modules. + for (const match of source.matchAll(/^\s*import[^;\n]*from\s+"([^"]+)"/gm)) { + assert.ok( + match[1].startsWith("node:"), + `${relative(repoRoot, path)} imports ${match[1]}`, + ); + } + } +}); + +test("the CLI entry point returns a contract-shaped receipt", () => { + const { root, person } = workspace(); + try { + const result = spawnSync(process.execPath, [CLI, "retrospect", "--person", SLUG, "--json"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + const receipt = JSON.parse(result.stdout.slice(result.stdout.indexOf("{"))); + assert.equal(receipt.command, "retrospect"); + assert.equal(receipt.person, SLUG); + assert.equal(receipt.ok, true); + assert.ok(Array.isArray(receipt.inputs) && receipt.inputs.length === 4); + assert.ok(Array.isArray(receipt.outputs) && receipt.outputs.length === DERIVED_KINDS.length); + assert.ok(Array.isArray(receipt.warnings)); + assert.ok(Array.isArray(receipt.unavailable)); + for (const item of [...receipt.inputs, ...receipt.outputs]) { + assert.match(item.sha256, /^[0-9a-f]{64}$/); + assert.equal(typeof item.bytes, "number"); + assert.equal(typeof item.path, "string"); + } + + const help = spawnSync(process.execPath, [CLI, "retrospect", "--help"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(help.status, 0); + assert.match(help.stdout, /用法 \/ Usage/); + assert.match(help.stdout, /Options:/); + + const missing = spawnSync(process.execPath, [CLI, "retrospect", "--person", "nobody", "--json"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(missing.status, 2, "a missing ledger must not exit 0"); + const failure = JSON.parse(missing.stdout.slice(missing.stdout.indexOf("{"))); + assert.equal(failure.ok, false); + assert.equal(failure.error.code, "retrospect/missing-input"); + assert.equal(typeof failure.error.remedy, "string"); + assert.deepEqual(readdirSync(derivedDir(person)).length, DERIVED_KINDS.length); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From abfc4fe73d1b07b88b16c1ebd24161195e29bf84 Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 4/7] docs(evidence): record the retrospect derivation evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:docs(evidence): record the retrospect derivation evidence --- docs/evidence/pr-06-retrospect.md | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/evidence/pr-06-retrospect.md diff --git a/docs/evidence/pr-06-retrospect.md b/docs/evidence/pr-06-retrospect.md new file mode 100644 index 00000000..bf6b030e --- /dev/null +++ b/docs/evidence/pr-06-retrospect.md @@ -0,0 +1,179 @@ +# PR-06 · `retrospect`:确定性回望派生 + +- 分支:`ds/06-retrospect`(基于 `dot-skill-test`) +- 交付:`src/derive/retrospect.mjs`、`src/derive/fixtures/synthetic-group/**`、`tests/retrospect.test.mjs`、`bin/distilly.mjs` 的最小注册 +- 依赖:无(不 import `src/knowledge/**`,等 ds/02 落地后也不冲突);**零运行时依赖、零模型调用、零网络** +- 本文纯文字,无截图;证据目录 `dst-evidence/` 未入库(`.gitignore` 已含) +- 按用户指令**不 push、不建 PR**:以下提交都在本地 `ds/06-retrospect` 上 + +## 1. 变更 + +| # | 提交 | 内容 | +| --- | --- | --- | +| 1 | `test(derive): add the synthetic ledger fixture for retrospect` | `src/derive/fixtures/synthetic-group/`:一份形状与契约一致、特征事先已知的合成账本(4 说话人 / 71 条 / 3 个来源)。`.gitignore` 增加两条否定规则,只把 fixtures 下的 `knowledge/` 重新纳入版本控制 | +| 2 | `feat(derive): derive stats from the ledger, deterministically` | 模块骨架:读账本与 `knowledge/text/*.md`、锚点解析与「不可回指就不引用」的纪律、`stableStringify` + `round(4)`、`run(args, io)` CLI 入口与契约形状回执、`stats` 维度 | +| 3 | `feat(derive): derive voice and relations from the ledger` | `voice`(句长 / 标点密度与构成 / 表情密度 / 口头禅 n-gram / 称呼用法 / 疑问比例)与 `relations`(回应频次、回应不对称、发起分布、回应间隔、称呼用法与称呼变化) | +| 4 | `feat(derive): derive timeline phases and shift candidates` | `timeline`(阶段切分 + 阶段特征 + 最大阶段差异)与 `shifts`(滑窗 + 双阈值的突变点候选) | +| 5 | `feat(derive): derive avoidance candidates and contradictions` | `boundaries`(R1/R2/R3/R4 四条可解释规则)与 `conflicts`(褒贬 + 确定程度两个立场的相反句子对) | +| 6 | `feat(cli): register the retrospect subcommand` | `bin/distilly.mjs` 里**一段自包含分支**:动态 import、`process.exitCode` 透传、位置放在通用 `--help` 之前以便该子命令拥有自己的双语帮助。没有重构 install/uninstall | +| 7 | `test(derive): assert anchor resolution, determinism and the empty path` | `tests/retrospect.test.mjs`:7 条断言,见 §2 | + +文件清单(新增 6 + 修改 2): + +``` +src/derive/retrospect.mjs 新增 +src/derive/fixtures/synthetic-group/README.md 新增 +src/derive/fixtures/synthetic-group/knowledge/index.json 新增 +src/derive/fixtures/synthetic-group/knowledge/text/group-chat.md 新增 +src/derive/fixtures/synthetic-group/knowledge/text/dm-lin-chen.md 新增 +src/derive/fixtures/synthetic-group/knowledge/text/incident-postmortem.md 新增 +tests/retrospect.test.mjs 新增 +bin/distilly.mjs 修改(+8) +.gitignore 修改(+3) +docs/evidence/pr-06-retrospect.md 新增(本文) +``` + +合计 `9 files changed, 3024 insertions(+)`(其中 `src/derive/retrospect.mjs` 2350 行、`tests/retrospect.test.mjs` 449 行)。 + +未触碰:`src/knowledge/**`、`src/parse/**`、`src/skill/**`、`src/views/**`、`src/install/**`、`src/collect/**`、`SKILL.md`、`prompts/**`、`docs/v2/**`、其他 `tests/*.test.mjs`。 + +## 2. 测试命令与结果 + +``` +$ node --test tests/retrospect.test.mjs +ok 1 - the fixture ledger digests match the fixture text bytes +ok 2 - seven files are written and every claim cites a resolvable anchor +ok 3 - two runs over the same ledger are byte-identical +ok 4 - the features the fixture was built around are actually found +ok 5 - two messages yield empty claim lists and a stated reason +ok 6 - the derivation calls no network and no model +ok 7 - the CLI entry point returns a contract-shaped receipt +# tests 7 / pass 7 / fail 0 +``` + +Node v22.23.1(要求 ≥ 20)。`node --test` 不指定文件时,本分支 `tests/` 下只有这一个 `.mjs`,同样 7/7。 + +逐条对应任务要求: + +| 要求 | 落在哪条 | 结果 | +| --- | --- | --- | +| 夹具 ≥3 说话人、≥60 条、含语调突变与话题回避;7 个文件都生成 | 4 + 2 | 通过(4 人 / 71 条;`shifts` 找到长度突变,`boundaries` 找到两处回避,`conflicts` 找到同一人前后矛盾与跨人分歧,`relations` 找到「林工→林哥」) | +| 每条 claim 至少 1 个 `evidence`,且都能在夹具账本里回指 | 2 | 通过(38 条 claim / 106 个引用 / 46 个不同锚点 / 0 悬空;脚本另按 `docs/v2/ACCEPTANCE.md` §5 的正则 `k\d{4}(?::t\d+)?` 扫一遍字节,同样 0 悬空) | +| 两次运行 sha256 相同 | 3 | 通过(同进程两次 + 换目录 + **两个独立 `bin/distilly.mjs` 进程**,三种都比对逐文件 sha256) | +| 样本不足:只给 2 条 → 空 claims + notes 给理由 | 5 | 通过(7 个文件全部 `claims: []`,note 写明「只有 2 条 …低于 …8」;退出码 0,不抛错) | +| 没有网络/模型调用 | 6 | 通过(源码扫描 + 只允许 `node:` import) | + +### 2.1 反向对照(证明断言真的会红) + +在 `/tmp` 的整份副本上做变异,测试文件不动: + +| 变异 | 结果 | +| --- | --- | +| A:把一个值改成依赖 `process.pid` | `not ok 3`(**只有跨进程那条能抓到**:同进程两次跑不出差别) | +| B:某条 claim 引用一个账本没声明的锚点 `k9999` | `not ok 2` | +| C:把样本不足分支的 `notes` 清空 | `not ok 5` | +| D:`makeClaim` 返回的 claim 里 `evidence` 改成 `[]` | `not ok 2` + `not ok 4` | + +## 3. 夹具规模与各维度产出条数 + +夹具:`src/derive/fixtures/synthetic-group`,4 个说话人(林工 / 小陈 / 老周 / 阿May),71 条消息, +3 个来源(群聊 44 条、1:1 私聊 26 条、复盘文档 1 段),时间跨度 2024-03-04 → 2024-03-20(16.256 天), +账本声明 71 个锚点(`k0001:t1`…`k0001:t44`、`k0002:t1`…`k0002:t26`、`k0003`,两种粒度都有)。 + +| 维度 | claims | evidence 引用 | 不同锚点 | notes | 说明 | +| --- | --- | --- | --- | --- | --- | +| `stats` | 8 | 23 | 8 | 0 | 条数 / 来源数 / 参与者 / 人数 / 时间范围 / 跨度 / 密度 / 长度分布 | +| `voice` | 11 | 36 | 24 | 0 | 句长、标点密度、标点构成、表情、5 条口头禅、称呼用法、疑问比例 | +| `relations` | 6 | 16 | 11 | 0 | 回应频次、回应不对称、发起分布、回应间隔、称呼用法、称呼变化 | +| `timeline` | 4 | 11 | 9 | 0 | 3 个阶段 + 1 条最大阶段差异 | +| `shifts` | 2 | 6 | 6 | 1 | 2 个长度突变点(故障开始 +19.75 字/条,回到常态 −27.83 字/条) | +| `boundaries` | 4 | 8 | 8 | 1 | 2 条 R1+R3(offer 被两次挡回)+ 2 条 R2(问句后极短回复) | +| `conflicts` | 3 | 6 | 6 | 1 | 褒贬 2 条(同一人「远程办公」、跨人「这个方案」)+ 确定程度 1 条(「幂等键」) | +| **合计** | **38** | **106** | **46**(账本 71 个锚点里被引用 46 个) | 3 | 置信度分布:high 18 / medium 19 / low 1 | + +候选类维度(`boundaries` / `conflicts` / `shifts`)每条都带触发它的规则名与 ≤40 字的逐字摘录; +`timeline` 的每个阶段带 `from`/`to`/`basis`;`shifts` 把滑窗宽度与两个阈值写进 `notes`。 + +## 4. 两次运行的 sha256 + +命令(`--person synthetic-group` 在含 `skills/colleague/synthetic-group/` 的工作目录下执行): + +``` +$ node bin/distilly.mjs retrospect --person synthetic-group --json # 第一次 +$ node bin/distilly.mjs retrospect --person synthetic-group --json # 第二次 +``` + +两次 `evidence/derived/*.json` 的逐文件 sha256 **完全一致**(`diff` 为空),回执本身也逐字节一致: + +| 文件 | sha256 | +| --- | --- | +| `stats.json` | `03aa610363f957eb416a8a3e8a8e9141a7522dbdc4bf0bebfce6d3be6baaa695` | +| `voice.json` | `90f995af3664c3c18d83c5ed95bc343fe361a2c452a137a0b3f1306cd0c88259` | +| `relations.json` | `8dcddd11b7c497957885cd054c8b5597a9bd36fdcc7749ecf1fc10001be2dde4` | +| `timeline.json` | `0160da649219211a261da10f63ce70fdcfb4bd195b87f2dea80bd284ee80c4b1` | +| `boundaries.json` | `e0429a12710b7a5303af29185daf75a30f85b51cdd7b7be162383507cfd20de2` | +| `shifts.json` | `60e506618ff44f8fc9e92161fffecf5f1a01441c5191744e93df52ddab734a51` | +| `conflicts.json` | `2c3407f2b2cadb7704f0607efbe01d7e6238d66b66902d997175b1c85f6ba031` | +| 7 个文件的 sha256 再取 sha256 | `25fa1847bc43a3cda2158715db53a1d12106b7f6ffce62a91d4eefcd72daf99c` | + +做法上保证确定性的四件事:没有 `Date.now()`/`Math.random()`/`new Date()`(时间戳用手写 UTC 解析,`Date` 只接受毫秒数); +JSON 一律按键排序输出;浮点统一 `round(4)`;目录与锚点一律排序后遍历。 + +## 5. 真实验收形状的预演 + +`scripts/acceptance.mjs` 在本分支上会在第一步 `harvest` 就响亮失败(缺 ds/01、ds/02 的产出),这是预期行为。 +为了提前排掉「合并后才炸」的风险,我按它生成账本的方式(`[k0001] ` 段落级锚点、无时间戳、38 条字幕) +手工造了一份同形状的 `knowledge/`,结果: + +- 退出码 0,7 个文件齐全,共 **22 条 claim**,逐文件 sha256 两次一致,**0 悬空锚点**; +- `stats.participants` 正确给出 `["林工","面试官"]`(为此把参与者阈值从 3 降到 2:1:1 语料是最常见形态,不能因为只有两个说话人就整维度留空); +- 没有逐条时间戳时,`timeline` 用 `basis: "order"` 明确标注自己是按账本顺序三分位、`from`/`to` 为 `null`,`notes` 写明原因; +- `boundaries` / `conflicts` 在该语料上是空集 + 说明(合成访谈里没有触发词表与极短回复模式),**没有编造**。 + +## 6. 已知缺口与未验证项 + +**方法本身的边界(都写进了 `notes`,不是失败)** + +1. 词表是固定的、小的:`boundaries` 的敏感话题 20 条、回避词 18 条,`conflicts` 两个立场维度各约 10 条。 + 语料若回避的是词表外的话题,或矛盾不靠显式立场词表达(「这个方案很稳」vs「这个方案会炸」),**不会被发现**。 + 这是刻意的:宁可空集 + 说明,也不塞进不可解释的启发式。 +2. `relations.address_shift` 只比较「第一次用的称呼 ≠ 最后一次用的称呼,且各 ≥2 次」。称呼来回摇摆、三段式变化只报首尾。 +3. `timeline` 是**等条数**三分位,不是等时长:活动集中在一周的语料,某个阶段可能只跨 5 分钟,另一个跨 3 周。 + `from`/`to` 让这件事可见,但阶段边界不对齐日历。 +4. `stats.density_per_hour` 是全跨度的平均值,不反映突发性(故障期一小时 30 条、其余一天 5 条,会平均掉)。 +5. `shifts` 只看两个指标(平均长度、感叹号消息比例),不看标点密度、表情或情绪突变。 +6. 分句只处理中日韩句末标点与拉丁 `.`/`!`/`?`;小数点会被误判成句末(`3.14` 会在 `3.` 处断开)。夹具与验收语料都不含小数,未验证。 +7. 中文中心:英文语料能拿到长度/标点/表情/疑问比例,但拿不到口头禅(CJK n-gram)与矛盾(词表是中文)。 +8. `stats.time_range` / `time_span_days` 只有两个锚点可以钉住范围,按置信度规则封顶 `medium`——这是规则使然,不是样本不足。 +9. 时间戳只认 ISO 8601(含日期-only、`Z` 与 `±HH:MM`)。epoch 毫秒、本地化格式会退回 `order` 基准。 +10. `--person` 会在 `skills/*//` 里找;同名 slug 出现在两个 family 时按目录名排序取第一个(确定但武断)。 + +**未验证** + +- 没有用真实私聊/邮件语料跑过(按 `docs/v2/ACCEPTANCE.md` §6,那类语料不进仓库也不进 CI)。 +- 没有跑过 `scripts/acceptance.mjs` 的全绿路径:它在 `harvest` 处就停下(缺 ds/01、ds/02)。§5 是我能在这条分支上做到的最接近的预演。 +- 没有验证 20 万条量级的账本:`conflicts` 用了倒排索引、`shifts` 是滑窗,`voice` 的 n-gram 是每说话人一张表,但整体没有做过性能基准。 +- 没有验证账本声明了锚点、正文里却找不到该锚点的情形(只会出现在 ds/02 半写完的账本上);此时该锚点会被静默丢弃并给出 warning,warning 文案本身没被测试覆盖。 + +## 7. 回滚 + +- 整个 PR 可以整体回滚:`git revert --no-commit <7 个提交>` 然后一次提交;删除 `src/derive/**`、`tests/retrospect.test.mjs`、`docs/evidence/pr-06-retrospect.md` 即可,`bin/distilly.mjs` 只需要去掉那一段 `else if (args[0] === "retrospect")`。 +- `retrospect` 只写 `evidence/derived/*.json`,**不改任何输入**(`knowledge/` 只读)。回滚后残留的派生文件是惰性的,可直接 `rm -rf evidence/derived` 重建。 +- 与其他分支的耦合点只有两处:`bin/distilly.mjs`(ds/01 会重写整个文件,冲突时保留「一段独立的 retrospect 分支 + 动态 import」这个形状即可)和 `.gitignore`(三行否定规则,只影响 fixtures)。 +- 夹具是纯测试数据,删掉它只会让 `tests/retrospect.test.mjs` 变红,不影响任何生产路径。 + +--- + +## English summary + +`retrospect` derives `evidence/derived/{stats,voice,relations,timeline,boundaries,shifts,conflicts}.json` +from `knowledge/index.json` + `knowledge/text/*.md`, with zero runtime dependencies, zero model +calls and zero network access. Every claim cites at least one anchor **that the ledger itself +declares**, so the acceptance gate's anchor-resolution assertion cannot fail by construction; +when the ledger only declares paragraph-level anchors the units are merged up to that granularity +and a note says so. Seven tests cover anchor resolution, byte-identical reruns across three +comparisons (including two separate CLI processes), the fixture's known features, the +two-message empty path, and a source scan forbidding any network or model call. On the synthetic +fixture: 38 claims, 106 evidence references, 46 distinct anchors, 0 dangling. Known gaps: the +lexicons behind `boundaries` and `conflicts` are small and fixed, `timeline` phases are +equal-count rather than equal-time, and the whole module is Chinese-centric. From c9c471824e9f657fd64a5dfe1c48ab54ba6d0d3e Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 5/7] =?UTF-8?q?chore(text-attribution.test):=20=E9=87=8D?= =?UTF-8?q?=E5=BB=BA=20tests/text-attribution.test.mjs=EF=BC=88=E5=8E=9F?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=BF=A1=E6=81=AF=E6=9C=AA=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:import io --- tests/text-attribution.test.mjs | 189 ++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tests/text-attribution.test.mjs diff --git a/tests/text-attribution.test.mjs b/tests/text-attribution.test.mjs new file mode 100644 index 00000000..43ef62b9 --- /dev/null +++ b/tests/text-attribution.test.mjs @@ -0,0 +1,189 @@ +/** + * Speaker and time must survive into `knowledge/text/*.md`. + * + * That file is the only thing the derivation reads: a speaker that stops at the + * parser is invisible to `retrospect`, which is how `voice`/`relations` ended up + * describing "the pair" instead of the person (see `docs/evidence/pr-10-blind-test-runs.md`). + * The prefix is render-time markup, exactly like the `[k0012]` anchor, so the + * "anchor text equals source bytes" invariant stays intact — both halves are + * asserted here. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseReceipt, runCli } from "./helpers/cli.mjs"; +import { loadLedger, resolveLedgerAnchor } from "../src/knowledge/ledger.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const CHAT_FIXTURES = join(here, "fixtures", "parse", "chat"); + +function tempRoot() { + return mkdtempSync(join(tmpdir(), "dst-attribution-")); +} + +/** A Slack export directory: messages.json + the users.json it ships with. */ +function slackExport(root, messages = 12) { + const dir = join(root, "corpus"); + mkdirSync(dir, { recursive: true }); + const base = 1_700_000_000; + const rows = []; + for (let index = 0; index < messages; index += 1) { + const alice = index % 2 === 0; + rows.push({ + type: "message", + user: alice ? "U01" : "U02", + text: alice ? `Alice line ${index}: 先看数据,再看日志,最后才看代码。` : `Bob line ${index}: 同意,不过灰度要分批。`, + ts: `${base + index * 90}.000100`, + }); + } + writeFileSync(join(dir, "messages.json"), `${JSON.stringify(rows, null, 2)}\n`, "utf8"); + // This synthetic export names its own people, so it ships its own users.json: + // the rows above use U01/U02, while the shared fixture uses U01SYNTH/U02SYNTH. + // Copying that one would silently leave the ids unresolved and the assertions + // below would be testing the wrong thing. + writeFileSync( + join(dir, "users.json"), + `${JSON.stringify( + [ + { id: "U01", name: "alice", profile: { display_name: "Alice", real_name: "Alice" } }, + { id: "U02", name: "bob", profile: { display_name: "Bob", real_name: "Bob" } }, + ], + null, + 2, + )}\n`, + "utf8", + ); + return dir; +} + +function harvest(root, corpus, person = "conv", extra = []) { + const result = runCli(["harvest", corpus, "--person", person, "--base-dir", root, "--json", ...extra]); + assert.equal(result.status, 0, result.stderr); + return result; +} + +function textOf(root, person = "conv") { + const dir = join(root, "skills", "colleague", person, "knowledge", "text"); + return readdirSync(dir) + .sort() + .map((name) => ({ name, body: readFileSync(join(dir, name), "utf8") })); +} + +test("the normalised markdown carries ` :`", () => { + const root = tempRoot(); + try { + harvest(root, slackExport(root)); + const [file] = textOf(root); + // The text file is named after the **source label**, and `harvest` takes that + // from the directory it read (`corpus/` here) unless `--source` overrides it — + // `--source chat` restores `chat.md`. Asserting the literal name pinned the + // parser's old hardcoded default instead of the plumbing that sets it. + assert.match(file.name, /^[a-z0-9-]+\.md$/, `one text file per source, got ${file.name}`); + // Names come from the sibling users.json the export ships with. + assert.match(file.body, /^\[k0001\] 2023-11-14T22:13:20\.000Z Alice:Alice line 0/m); + assert.match(file.body, /^\[k0002\] 2023-11-14T22:14:50\.000Z Bob:Bob line 1/m); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("nothing is prefixed twice, and the paragraph text stays verbatim", () => { + const root = tempRoot(); + try { + const subtitle = join(here, "fixtures", "parse", "subtitle", "interview.srt"); + harvest(root, subtitle, "sub"); + const [file] = textOf(root, "sub"); + // This fixture's second cue already reads `Lin: …`; the renderer must not + // turn it into `Lin:Lin: …`. + assert.equal(file.body.includes("Lin:Lin:"), false, file.body.slice(0, 200)); + + // The anchor's own text is still the source wording: the prefix is markup, so + // no speaker or timestamp leaks into the citation. A subtitle anchor covers + // the cue's envelope (index + timecode + text), so the invariant is that the + // unit text sits verbatim inside that range. + const ledger = loadLedger({ root: join(root, "skills", "colleague", "sub", "knowledge") }); + const unit = resolveLedgerAnchor(ledger, "k0002"); + assert.ok(unit, "k0002 must resolve"); + const raw = readFileSync(join(root, "skills", "colleague", "sub", "knowledge", "raw", "subtitle", "interview.srt")); + const slice = raw.subarray(unit.byteStart, unit.byteEnd).toString("utf8"); + assert.equal(slice.includes(unit.text), true, `the cue text must sit verbatim in its byte range:\n${slice}`); + assert.equal(unit.text.startsWith("Lin"), true, `expected the cue text itself, got ${unit.text.slice(0, 40)}`); + assert.equal(unit.text.includes("00:00:03"), false, "the timecode is envelope, not citation text"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("attribution reaches the derivation: per-speaker voice stats and dated phases", () => { + const root = tempRoot(); + try { + harvest(root, slackExport(root)); + const retro = runCli(["retrospect", "--person", "conv", "--json"], { cwd: root }); + assert.equal(retro.status, 0, retro.stderr); + const receipt = parseReceipt(retro.stdout); + assert.deepEqual(receipt.warnings, [], "a clean chat corpus derives without warnings"); + assert.ok(receipt.anchors.cited > 0); + + const derived = join(root, "skills", "colleague", "conv", "evidence", "derived"); + const voice = JSON.parse(readFileSync(join(derived, "voice.json"), "utf8")); + const length = voice.claims.find((claim) => claim.id === "voice.sentence_length"); + assert.ok(length, "sentence length must be derived"); + assert.equal(length.value.pooled, true, "the pooled number says that it pools speakers"); + assert.deepEqual(Object.keys(length.value.by_speaker).sort(), ["Alice", "Bob"]); + assert.equal(length.value.by_speaker.Alice.samples, 6); + for (const [speaker, stats] of Object.entries(length.value.by_speaker)) { + assert.ok(stats.median > 0, `${speaker} needs a median`); + } + + const stats = JSON.parse(readFileSync(join(derived, "stats.json"), "utf8")); + assert.deepEqual(stats.claims.find((claim) => claim.id === "stats.participants").value, ["Alice", "Bob"]); + + // Timestamps reached the derivation too: the phases are time-based now. + const timeline = JSON.parse(readFileSync(join(derived, "timeline.json"), "utf8")); + const phase = timeline.claims.find((claim) => claim.id.startsWith("timeline.phase")); + assert.equal(phase.value.basis, "time", "phases must be dated, not order-based"); + assert.match(phase.value.from, /^\d{4}-\d{2}-\d{2}T/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("two exports under one --source no longer overwrite each other's text", () => { + const root = tempRoot(); + try { + const corpus = slackExport(root); + // A second chat document, same source bucket: this used to replace chat.md. + const second = join(root, "second.json"); + const rows = JSON.parse(readFileSync(join(corpus, "messages.json"), "utf8")); + writeFileSync( + second, + `${JSON.stringify(rows.map((row, index) => ({ ...row, text: `second export line ${index}` })), null, 2)}\n`, + "utf8", + ); + + harvest(root, corpus); + harvest(root, second, "conv", ["--source", "chat"]); + + const files = textOf(root); + assert.equal(files.length, 2, `expected two text files, got ${files.map((file) => file.name).join(", ")}`); + const bodies = files.map((file) => file.body).join("\n"); + assert.match(bodies, /Alice line 0/); + assert.match(bodies, /second export line 0/); + + const ledger = loadLedger({ root: join(root, "skills", "colleague", "conv", "knowledge") }); + assert.equal(ledger.length, 2); + for (const entry of ledger) { + assert.ok(entry.locations.text, `${entry.id} must point at a text file`); + for (const unit of entry.units ?? []) { + assert.ok(resolveLedgerAnchor(ledger, unit.anchor), `${unit.anchor} must resolve`); + } + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); From 2c6e47f5a535992c1d017fbcc80647f29075f77a Mon Sep 17 00:00:00 2001 From: zhoutianyi Date: Tue, 15 Sep 2026 17:35:35 +0800 Subject: [PATCH 6/7] =?UTF-8?q?chore(commands):=20=E9=87=8D=E5=BB=BA=207?= =?UTF-8?q?=20=E4=B8=AA=E6=96=87=E4=BB=B6=EF=BC=88=E5=8E=9F=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BF=A1=E6=81=AF=E6=9C=AA=E8=AE=B0=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重建说明:本提交由会话转录重建,提交信息取自原分支(ds/06-retrospect)。 文件内容为集成分支上的最终态,不是当时那一刻的中间态——原分支的 per-commit 文件树随 /tmp 清空丢失,转录只保留了提交信息与 git add 的路径清单。 原提交信息:(未记录) --- src/commands/retrospect.mjs | 47 + src/derive/dimensions.mjs | 838 ++++++++++++++++++ src/derive/fixtures/synthetic-group/README.md | 29 + .../synthetic-group/knowledge/index.json | 114 +++ .../knowledge/text/dm-lin-chen.md | 26 + .../knowledge/text/group-chat.md | 44 + .../knowledge/text/incident-postmortem.md | 1 + 7 files changed, 1099 insertions(+) create mode 100644 src/commands/retrospect.mjs create mode 100644 src/derive/dimensions.mjs create mode 100644 src/derive/fixtures/synthetic-group/README.md create mode 100644 src/derive/fixtures/synthetic-group/knowledge/index.json create mode 100644 src/derive/fixtures/synthetic-group/knowledge/text/dm-lin-chen.md create mode 100644 src/derive/fixtures/synthetic-group/knowledge/text/group-chat.md create mode 100644 src/derive/fixtures/synthetic-group/knowledge/text/incident-postmortem.md diff --git a/src/commands/retrospect.mjs b/src/commands/retrospect.mjs new file mode 100644 index 00000000..0fc26e76 --- /dev/null +++ b/src/commands/retrospect.mjs @@ -0,0 +1,47 @@ +/** + * `distilly retrospect` — deterministic retrospection (from ds/06-retrospect). + * + * The derivation itself lives in `src/derive/retrospect.mjs`; this module only + * registers it with the command registry and routes its output through the + * shared reporter so `--json` still produces exactly one object on stdout. + */ + +import { register } from "./index.mjs"; +import { run as runRetrospect } from "../derive/retrospect.mjs"; + +const help = { + zh: [ + "用法 / Usage:", + " distilly retrospect [--person ] [--dir ] [--json]", + "", + "选项 / Options:", + " --person 要回望的 Skill slug", + " --dir 指定 skills 根目录(默认当前目录)", + " --json 输出 JSON 回执", + "", + "从 knowledge/index.json 与 knowledge/text/*.md 派生 evidence/derived/*.json:", + "每条结论都带可回指的锚点;同一输入跑两次产物字节相同;样本不足时输出空集并说明理由。", + ].join("\n"), + en: [ + "Distilly retrospect — deterministic retrospection", + "", + "Derives evidence/derived/*.json from knowledge/index.json and knowledge/text/*.md.", + "Every claim carries resolvable anchors, two runs are byte-identical, and a thin", + "sample produces an empty set with a stated reason instead of a guess.", + ].join("\n"), +}; + +register("retrospect", { + summary: "确定性回望派生 / deterministic retrospection", + usage: "distilly retrospect [--person ] [--dir ] [--json]", + ...help, + run({ argv, json, reporter }) { + const sink = (write) => ({ write: (chunk) => write(String(chunk).replace(/\n$/, "")) }); + const io = json + ? { stdout: { write: () => {} }, stderr: { write: () => {} } } + : { stdout: sink((line) => line && reporter.line(line)), stderr: sink((line) => line && reporter.warn(line)) }; + const result = runRetrospect(argv, io) ?? {}; + return { receipt: result.receipt, exitCode: result.exitCode ?? 0 }; + }, +}); + diff --git a/src/derive/dimensions.mjs b/src/derive/dimensions.mjs new file mode 100644 index 00000000..922013ba --- /dev/null +++ b/src/derive/dimensions.mjs @@ -0,0 +1,838 @@ +/** + * dimensions.mjs — the six derivation dimensions `retrospect` was missing. + * + * The recovered `retrospect.mjs` had `stats` only: `voice`, `relations`, + * `timeline`, `boundaries`, `shifts` and `conflicts` were declared in + * `DERIVED_KINDS` and had thresholds in `MIN_UNITS`, but no deriver — so six of + * the seven output files were always empty, the acceptance page could only fill + * two of its seven authored segments, and no citation ever reached the page. + * + * Everything here obeys the same three rules as `stats`: + * + * 1. **A claim without a citable anchor is not emitted.** `makeClaim` returns + * `null` when there is nothing to cite, and the assembly drops the `null`s. + * 2. **Nothing is invented.** A dimension that cannot be measured on this + * corpus says so in `notes` instead of producing a plausible-looking claim. + * 3. **No clocks, no randomness.** Every list is sorted before it is summarised, + * so two runs over the same ledger are byte-identical. + * + * The features each dimension is expected to find on the bundled fixture are + * listed in `src/derive/fixtures/synthetic-group/README.md`; that table is the + * spec these implementations were written against. + */ + +/* ------------------------------------------------------------------ */ +/* small statistics */ +/* ------------------------------------------------------------------ */ + +const round = (value) => (Number.isFinite(value) ? Math.round(value * 10000) / 10000 : null); + +const mean = (numbers) => (numbers.length === 0 ? null : round(numbers.reduce((total, value) => total + value, 0) / numbers.length)); + +function median(numbers) { + if (numbers.length === 0) return null; + const sorted = numbers.slice().sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return round(sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]); +} + +function percentile(numbers, fraction) { + if (numbers.length === 0) return null; + const sorted = numbers.slice().sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1)); + return round(sorted[index]); +} + +const unique = (values) => [...new Set(values)]; + +/** Characters that carry meaning, for length comparisons. */ +const countChars = (text) => [...String(text ?? "")].filter((char) => !/\s/.test(char)).length; + +/** Sentence-ish units. CJK full stop, ASCII stop, exclamation, question, ellipsis. */ +const SENTENCE_SPLIT = /[。!?!?…]+/; +const sentencesOf = (text) => + String(text ?? "") + .split(SENTENCE_SPLIT) + .map((piece) => piece.trim()) + .filter((piece) => piece !== ""); + + +/* ------------------------------------------------------------------ */ +/* voice */ +/* ------------------------------------------------------------------ */ + +const PUNCTUATION = /[,。!?、;:""''()《》…—,.!?;:()"']/g; +const EMOJI = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu; +const QUESTION = /[??]/; +/** + * A probe does not have to end in a question mark: real chat leaves the question + * implicit ("那offer的事..."), and the fixture's second deflection is exactly + * that shape. An interrogative word or a trailing ellipsis counts too — but a + * bare statement does not, or every refusal would claim the previous message as + * its probe. + */ +const PROBE_LIKE = /[??]|(?:吗|呢|怎么|为什么|什么|哪|谁|是否|有没有)[??]?|(?:\.\.\.|…)\s*$/; +const ADDRESS_SUFFIXES = ["哥", "姐", "总", "工", "老师", "老板", "同学"]; + +/** Stop words that would otherwise dominate the n-gram counts. */ +const STOP_WORDS = new Set(["这个", "那个", "我们", "你们", "他们", "什么", "怎么", "可以", "还是", "就是", "一下", "一个", "没有", "不是"]); + +/** Half-open character n-grams, CJK-aware (no word segmentation available). */ +function ngramsOf(text, size) { + const clean = String(text ?? "").replace(/[\s,。!?、;:""''()《》…—,.!?;:()"']/g, ""); + const out = []; + for (let index = 0; index + size <= clean.length; index += 1) out.push(clean.slice(index, index + size)); + return out; +} + +function speakerStats(units, measure) { + const bySpeaker = {}; + for (const unit of units) { + const speaker = unit.speaker ?? null; + if (speaker === null) continue; + const values = measure(unit); + if (values.length === 0) continue; + if (!bySpeaker[speaker]) bySpeaker[speaker] = { samples: 0, values: [], anchors: [] }; + bySpeaker[speaker].samples += values.length; + bySpeaker[speaker].values.push(...values); + bySpeaker[speaker].anchors.push(unit.anchor); + } + const summarised = {}; + for (const speaker of Object.keys(bySpeaker).sort()) { + const entry = bySpeaker[speaker]; + summarised[speaker] = { + samples: entry.samples, + mean: mean(entry.values), + median: median(entry.values), + p90: percentile(entry.values, 0.9), + min: entry.values.length > 0 ? Math.min(...entry.values) : null, + max: entry.values.length > 0 ? Math.max(...entry.values) : null, + anchors: unique(entry.anchors).sort(), + }; + } + return summarised; +} + +const anchorList = (bySpeaker) => unique(Object.values(bySpeaker).flatMap((entry) => entry.anchors)).sort(); + +/** + * How one person talks: sentence length, punctuation density, emoji, catch + * phrases, address terms, question ratio. + * + * Per-speaker numbers are reported alongside the pooled one, because a corpus + * average over a terse engineer and a chatty PM describes neither of them. + */ +export function deriveVoice(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN } = helpers; + const claims = []; + const notes = []; + const withSpeaker = units.filter((unit) => unit.speaker !== null && unit.speaker !== undefined); + + // ---- sentence length ----------------------------------------------------- + const sentenceValues = (unit) => sentencesOf(unit.text).map(countChars).filter((value) => value > 0); + const bySpeaker = speakerStats(withSpeaker, sentenceValues); + const allSentences = units.flatMap(sentenceValues); + const speakerCount = Object.keys(bySpeaker).length; + + if (allSentences.length >= MIN.sentences) { + const anchorable = anchorList(bySpeaker); + const claim = makeClaim( + "voice.sentence_length", + "句长分布(字符数)", + "Sentence length (characters)", + { + unit: "chars", + mean: mean(allSentences), + median: median(allSentences), + p90: percentile(allSentences, 0.9), + sentences: allSentences.length, + // `pooled: true` means this number mixes speakers. When it does, the + // per-speaker breakdown below is the one to read. + pooled: speakerCount > 1, + mixes_speakers: speakerCount > 1, + by_speaker: bySpeaker, + }, + anchorable, + allSentences.length, + units.map((unit) => unit.anchor), + ); + if (claim) claims.push(claim); + else notes.push(note("句长可算但没有可引用锚点。", "Sentence lengths were computed but no citable anchor exists.")); + } else { + notes.push( + note( + `句长样本不足:只有 ${allSentences.length} 句,低于最低样本数 ${MIN.sentences}。`, + `Not enough sentences for length: ${allSentences.length} < ${MIN.sentences}.`, + ), + ); + } + + // ---- punctuation density ------------------------------------------------- + const punctuationValues = (unit) => { + const text = String(unit.text ?? ""); + if (text === "") return []; + return [(text.match(PUNCTUATION) ?? []).length / Math.max(1, countChars(text))]; + }; + const punctBySpeaker = speakerStats(withSpeaker, punctuationValues); + const allPunct = units.flatMap(punctuationValues); + if (allPunct.length >= MIN.punctuation) { + const claim = makeClaim( + "voice.punctuation_density", + "标点密度(每字符)", + "Punctuation density (per character)", + { + mean: mean(allPunct), + median: median(allPunct), + messages: allPunct.length, + pooled: Object.keys(punctBySpeaker).length > 1, + by_speaker: Object.fromEntries( + Object.entries(punctBySpeaker).map(([speaker, entry]) => [speaker, { samples: entry.samples, mean: entry.mean, median: entry.median }]), + ), + }, + anchorList(punctBySpeaker), + allPunct.length, + units.map((unit) => unit.anchor), + ); + if (claim) claims.push(claim); + } else { + notes.push(note(`标点样本不足:${allPunct.length} < ${MIN.punctuation}。`, `Not enough messages for punctuation: ${allPunct.length} < ${MIN.punctuation}.`)); + } + + // ---- emoji --------------------------------------------------------------- + const emojiUnits = units.filter((unit) => (String(unit.text ?? "").match(EMOJI) ?? []).length > 0); + if (emojiUnits.length >= MIN.emoji) { + const counts = emojiUnits.map((unit) => (String(unit.text).match(EMOJI) ?? []).length); + const claim = makeClaim( + "voice.emoji_density", + "表情使用", + "Emoji usage", + { + messages_with_emoji: emojiUnits.length, + messages: units.length, + per_message: round(emojiUnits.length / Math.max(1, units.length)), + max_in_one_message: Math.max(...counts), + }, + emojiUnits.map((unit) => unit.anchor), + emojiUnits.length, + ); + if (claim) claims.push(claim); + } else { + notes.push(note(`表情样本不足:${emojiUnits.length} < ${MIN.emoji}。`, `Not enough emoji messages: ${emojiUnits.length} < ${MIN.emoji}.`)); + } + + // ---- catch phrases ------------------------------------------------------- + const counts = new Map(); + const owners = new Map(); + for (const unit of units) { + for (const gram of ngramsOf(unit.text, 3)) { + if (STOP_WORDS.has(gram)) continue; + counts.set(gram, (counts.get(gram) ?? 0) + 1); + if (!owners.has(gram)) owners.set(gram, new Set()); + owners.get(gram).add(unit.anchor); + } + } + const repeated = [...counts.entries()].filter(([, count]) => count >= 3).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); + if (units.length >= MIN.ngram && repeated.length > 0) { + const phrases = repeated.slice(0, 8).map(([phrase, count]) => ({ + phrase, + count, + anchors: [...owners.get(phrase)].sort().slice(0, 3), + })); + const claim = makeClaim( + "voice.catchphrases", + "口头禅(重复三字组)", + "Catch phrases (repeated trigrams)", + { phrases }, + unique(phrases.flatMap((entry) => entry.anchors)), + units.length, + ); + if (claim) claims.push(claim); + } else { + notes.push(note(`口头禅样本不足:语料 ${units.length} 条 < ${MIN.ngram}。`, `Not enough text for catch phrases: ${units.length} < ${MIN.ngram}.`)); + } + + // ---- address terms ------------------------------------------------------- + const names = unique(units.flatMap((unit) => unit.speakers ?? [])); + const terms = new Map(); + for (const name of names) { + if (name.length < 2) continue; + const stem = name.slice(0, -1); + for (const suffix of ADDRESS_SUFFIXES) terms.set(`${stem}${suffix}`, name); + } + const addressUnits = new Map(); + for (const unit of units) { + for (const [term, owner] of terms) { + if (String(unit.text ?? "").includes(term)) { + if (!addressUnits.has(term)) addressUnits.set(term, { owner, anchors: [] }); + addressUnits.get(term).anchors.push(unit.anchor); + } + } + } + const used = [...addressUnits.entries()].filter(([, entry]) => entry.anchors.length >= 2).sort((left, right) => left[0].localeCompare(right[0])); + if (used.length > 0) { + const claim = makeClaim( + "voice.address_terms", + "称呼用法", + "Address terms", + { + terms: used.map(([term, entry]) => ({ term, refers_to: entry.owner, count: entry.anchors.length })), + }, + unique(used.flatMap(([, entry]) => entry.anchors)), + used.reduce((total, [, entry]) => total + entry.anchors.length, 0), + ); + if (claim) claims.push(claim); + } else if (units.length >= MIN.address) { + notes.push(note("没有找到重复出现的称呼词。", "No repeated address term was found.")); + } + + // ---- questions ----------------------------------------------------------- + const questionUnits = units.filter((unit) => QUESTION.test(String(unit.text ?? ""))); + if (units.length >= MIN.questions) { + const claim = makeClaim( + "voice.question_ratio", + "提问比例", + "Question ratio", + { questions: questionUnits.length, messages: units.length, ratio: round(questionUnits.length / units.length) }, + questionUnits.map((unit) => unit.anchor), + units.length, + units.map((unit) => unit.anchor), + ); + if (claim) claims.push(claim); + } else { + notes.push(note(`提问比例样本不足:${units.length} < ${MIN.questions}。`, `Not enough messages for the question ratio: ${units.length} < ${MIN.questions}.`)); + } + + return { claims: claims.filter(Boolean), notes }; +} + +/* ------------------------------------------------------------------ */ +/* timeline */ +/* ------------------------------------------------------------------ */ + +/** + * Dated phases. + * + * Only produced when the corpus carries timestamps: a phase without dates would + * be a phase in name only, and `docs/v2/RENDER.md` tells the page to show a gap + * instead of an invented axis. Each phase cites the anchors that bound it, so a + * reader can check the range rather than trust the label. + */ +export function deriveTimeline(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN } = helpers; + const claims = []; + const notes = []; + const timed = units.filter((unit) => unit.at !== null && unit.at !== undefined); + if (timed.length < MIN.phases) { + notes.push( + note( + `时间线样本不足:只有 ${timed.length} 条带时间戳的消息,低于 ${MIN.phases}。`, + `Not enough dated messages for a timeline: ${timed.length} < ${MIN.phases}.`, + ), + ); + return { claims, notes }; + } + + const first = timed[0].at; + const last = timed[timed.length - 1].at; + const span = Math.max(1, last - first); + const phaseCount = span > 0 ? Math.min(4, Math.max(2, Math.round(timed.length / 15))) : 1; + const buckets = Array.from({ length: phaseCount }, () => []); + for (const unit of timed) { + const index = span === 0 ? 0 : Math.min(phaseCount - 1, Math.floor(((unit.at - first) / span) * phaseCount)); + buckets[index].push(unit); + } + + buckets.forEach((bucket, index) => { + if (bucket.length === 0) return; + const anchors = bucket.map((unit) => unit.anchor); + const lengths = bucket.flatMap((unit) => sentencesOf(unit.text).map(countChars)).filter((value) => value > 0); + const speakers = unique(bucket.flatMap((unit) => unit.speakers ?? [])); + const claim = makeClaim( + `timeline.phase.${index + 1}`, + `阶段 ${index + 1}(按时间切分)`, + `Phase ${index + 1} (time-based)`, + { + // `time`, not `order`: the buckets come from timestamps, so an export + // that is missing dates cannot silently produce order-based phases. + basis: "time", + from: new Date(bucket[0].at).toISOString(), + to: new Date(bucket[bucket.length - 1].at).toISOString(), + messages: bucket.length, + speakers, + mean_chars: mean(lengths), + questions: bucket.filter((unit) => QUESTION.test(String(unit.text ?? ""))).length, + // The two anchors that bound the range: a phase claim that cited only its + // first message would not let a reader check where it ends. + range_anchors: { from: bucket[0].anchor, to: bucket[bucket.length - 1].anchor }, + }, + [anchors[0], anchors[anchors.length - 1], ...anchors.slice(1, 3)], + bucket.length, + anchors, + ); + if (claim) claims.push(claim); + }); + + claims.push( + makeClaim( + "timeline.span", + "时间跨度", + "Time span", + { + basis: "time", + from: new Date(first).toISOString(), + to: new Date(last).toISOString(), + days: round(span / 86_400_000), + messages: timed.length, + range_anchors: { from: timed[0].anchor, to: timed[timed.length - 1].anchor }, + }, + [timed[0].anchor, timed[timed.length - 1].anchor], + timed.length, + timed.map((unit) => unit.anchor), + ), + ); + + return { claims: claims.filter(Boolean), notes }; +} + +/* ------------------------------------------------------------------ */ +/* relations */ +/* ------------------------------------------------------------------ */ + +/** + * Who answers whom, and what they call each other. + * + * Reply counts come from adjacency (a turn answered within the same file, in + * order). The asymmetry report is the interesting part: "A answers B twice as + * often as B answers A" is a relationship fact, while the raw counts are not. + */ +export function deriveRelations(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN } = helpers; + const claims = []; + const notes = []; + + const replies = new Map(); // "A→B" -> anchors + for (let index = 1; index < units.length; index += 1) { + const from = units[index - 1]; + const to = units[index]; + if (from.file !== to.file) continue; + if (!from.speaker || !to.speaker || from.speaker === to.speaker) continue; + const key = `${from.speaker}→${to.speaker}`; + if (!replies.has(key)) replies.set(key, []); + replies.get(key).push(to.anchor); + } + + const totalReplies = [...replies.values()].reduce((total, list) => total + list.length, 0); + if (totalReplies >= MIN.interactions) { + const pairs = [...replies.entries()].sort((left, right) => left[0].localeCompare(right[0])); + const claim = makeClaim( + "relations.reply_counts", + "回应频次", + "Reply counts", + { + pairs: pairs.map(([pair, anchors]) => { + const [from, to] = pair.split("→"); + const reverse = replies.get(`${to}→${from}`)?.length ?? 0; + return { + from, + to, + replies: anchors.length, + reverse_replies: reverse, + asymmetry: round(anchors.length / Math.max(1, anchors.length + reverse)), + }; + }), + }, + unique(pairs.flatMap(([, anchors]) => anchors)).sort(), + totalReplies, + ); + if (claim) claims.push(claim); + } else { + notes.push(note(`回应样本不足:${totalReplies} < ${MIN.interactions}。`, `Not enough replies: ${totalReplies} < ${MIN.interactions}.`)); + } + + // ---- address shift ------------------------------------------------------- + // An address term is "how A calls B". A change *within one conversation* is + // the signal: the same pair moving from 林工 to 林哥 is a change in closeness, + // while two different files using different terms is just two contexts. + const names = unique(units.flatMap((unit) => unit.speakers ?? [])); + const variants = new Map(); + for (const name of names) { + if (name.length < 2) continue; + const stem = name.slice(0, -1); + for (const suffix of ADDRESS_SUFFIXES) { + // The canonical name is a term too, and it is usually the *earlier* one: + // 林工 in the first half, 林哥 in the second. Excluding it (the first + // version did) leaves nothing to shift *from*, so the change is invisible. + variants.set(`${stem}${suffix}`, name); + } + } + + let shiftIndex = 0; + for (const file of unique(units.map((unit) => unit.file)).sort()) { + const inFile = units.filter((unit) => unit.file === file && unit.speaker !== null); + if (inFile.length < 4) continue; + const half = Math.floor(inFile.length / 2); + const count = (slice) => { + const tally = new Map(); + for (const unit of slice) { + for (const [term, owner] of variants) { + if (String(unit.text ?? "").includes(term)) { + if (!tally.has(term)) tally.set(term, { owner, anchors: [] }); + tally.get(term).anchors.push(unit.anchor); + } + } + } + return tally; + }; + const firstHalf = count(inFile.slice(0, half)); + const secondHalf = count(inFile.slice(half)); + for (const [term, entry] of firstHalf) { + if (secondHalf.has(entry.owner) && !secondHalf.has(term)) { + // The person was called `owner` early and something else later. + const later = secondHalf.get(entry.owner); + shiftIndex += 1; + const claim = makeClaim( + `relations.address_shift.${shiftIndex}`, + `称呼变化:${entry.owner} → ${term}`, + `Address shift: ${entry.owner} → ${term}`, + { + from: entry.owner, + to: term, + file, + before: entry.anchors.length, + after: later.anchors.length, + }, + [...entry.anchors, ...later.anchors], + entry.anchors.length + later.anchors.length, + ); + if (claim) claims.push(claim); + } + } + + // The common case is the reverse spelling: the *later* half uses a variant + // that never appears early. Report that as the shift as well. + for (const [term, entry] of secondHalf) { + if (!firstHalf.has(term) && !firstHalf.has(entry.owner)) continue; + const earlierTerm = firstHalf.has(entry.owner) ? entry.owner : null; + if (earlierTerm === null) continue; + const already = claims.some((claim) => claim.id.startsWith("relations.address_shift.") && claim.value.to === term && claim.value.file === file); + if (already) continue; + shiftIndex += 1; + const claim = makeClaim( + `relations.address_shift.${shiftIndex}`, + `称呼变化:${earlierTerm} → ${term}`, + `Address shift: ${earlierTerm} → ${term}`, + { from: earlierTerm, to: term, file, before: firstHalf.get(earlierTerm).anchors.length, after: entry.anchors.length }, + [...firstHalf.get(earlierTerm).anchors, ...entry.anchors], + firstHalf.get(earlierTerm).anchors.length + entry.anchors.length, + ); + if (claim) claims.push(claim); + } + } + if (shiftIndex === 0 && units.length >= MIN.address) { + notes.push(note("没有发现同一段对话中的称呼变化。", "No address-term change was found inside one conversation.")); + } + + return { claims: claims.filter(Boolean), notes }; +} + +/* ------------------------------------------------------------------ */ +/* boundaries */ +/* ------------------------------------------------------------------ */ + +/** + * Refusals and deflections. + * + * Two rules, kept apart because they mean different things: + * + * R1 the person declines a *topic* ("先不说这个" / "这个不方便说") + * R3 the person declines *detail* ("不太想细说") + * + * `value.probe` is the question that preceded the refusal — but only when it is + * in the same session (within six hours). Without that gate the "probe" would + * routinely point at something said days earlier, which is not a probe. + */ +const SESSION_GAP_MS = 6 * 60 * 60 * 1000; + +const REFUSAL_RULES = [ + { + rule: "R1_topic_deflection", + // Tolerant of the infixes real speech inserts: 先不说 / 先不细说 / 这个不方便说. + pattern: /(?:先不|不方便|不想|别)(?:多|细|再)?(?:说|讲|谈|聊)|(?:这个|那个)(?:我)?(?:不方便|不好)(?:说|讲)|换个话题|跳过/, + }, + { + rule: "R3_explicit_refusal", + pattern: /不(?:太|怎么)?想(?:多|细|再)?(?:说|讲|谈)|(?:就|先)(?:这样|到这儿)|无可奉告/, + }, +]; + +export function deriveBoundaries(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN } = helpers; + const claims = []; + const notes = []; + + let index = 0; + for (const [position, unit] of units.entries()) { + const text = String(unit.text ?? ""); + const matched = REFUSAL_RULES.filter((entry) => entry.pattern.test(text)); + if (matched.length === 0) continue; + + // The question this refusal answers, if it is close enough in time to be one. + const previous = units[position - 1] ?? null; + const sameSession = + previous !== null && + previous.file === unit.file && + previous.at !== null && + unit.at !== null && + previous.at !== undefined && + unit.at !== undefined && + Math.abs(unit.at - previous.at) <= SESSION_GAP_MS; + const probe = sameSession && PROBE_LIKE.test(String(previous.text ?? "")) ? previous.anchor : null; + + const excerpt = text.length > 80 ? `${text.slice(0, 79)}…` : text; + const evidence = unique([...(probe === null ? [] : [probe]), unit.anchor]).sort(); + index += 1; + const claim = makeClaim( + `boundaries.candidate.${String(index).padStart(4, "0")}`, + `回避候选 ${index}(${matched.map((entry) => entry.rule).join("+")})`, + `Deflection candidate ${index} (${matched.map((entry) => entry.rule).join("+")})`, + { + speaker: unit.speaker ?? null, + deflection: matched.map((entry) => entry.pattern.exec(text)?.[0] ?? null).filter(Boolean), + excerpt, + probe, + rules: matched.map((entry) => entry.rule), + }, + evidence, + evidence.length, + ); + // A refusal is a *candidate*, never a conclusion: the rule fires on wording, + // and wording alone cannot tell a boundary from an ordinary aside. + if (claim) claims.push({ ...claim, confidence: "low" }); + } + + if (claims.length === 0) { + const why = units.length < MIN.boundaries ? `语料只有 ${units.length} 条,低于 ${MIN.boundaries}` : "语料里没有出现回避用语"; + notes.push(note(`没有回避候选:${why}。`, `No deflection candidate: ${units.length < MIN.boundaries ? `only ${units.length} messages, below ${MIN.boundaries}` : "no refusal wording appeared"}.`)); + } + return { claims, notes }; +} + +/* ------------------------------------------------------------------ */ +/* shifts */ +/* ------------------------------------------------------------------ */ + +/** + * Tone/length jumps. + * + * A sliding window compares the messages just before a point with the messages + * just after it; a point is a candidate when the relative change clears + * `SHIFT_RELATIVE` **and** the absolute change clears `SHIFT_ABSOLUTE`. Two + * thresholds because either alone produces noise: 40% of three characters is + * nothing, and ten characters on a 200-character baseline is nothing either. + */ +export function deriveShifts(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN, SHIFT_WINDOW_RATIO, SHIFT_WINDOW_MIN, SHIFT_WINDOW_MAX, SHIFT_RELATIVE, SHIFT_ABSOLUTE } = helpers; + const claims = []; + const notes = []; + + if (units.length < MIN.shifts) { + notes.push(note(`突变点样本不足:${units.length} < ${MIN.shifts}。`, `Not enough messages for shifts: ${units.length} < ${MIN.shifts}.`)); + return { claims, notes }; + } + + const window = Math.min(SHIFT_WINDOW_MAX, Math.max(SHIFT_WINDOW_MIN, Math.round(units.length * SHIFT_WINDOW_RATIO))); + const lengths = units.map((unit) => countChars(unit.text)); + const candidates = []; + + for (let index = window; index <= units.length - window; index += 1) { + const before = lengths.slice(index - window, index); + const after = lengths.slice(index, index + window); + const meanBefore = mean(before); + const meanAfter = mean(after); + if (meanBefore === null || meanAfter === null || meanBefore === 0) continue; + const relative = Math.abs(meanAfter - meanBefore) / meanBefore; + const absolute = Math.abs(meanAfter - meanBefore); + if (relative < SHIFT_RELATIVE || absolute < SHIFT_ABSOLUTE) continue; + candidates.push({ index, meanBefore, meanAfter, relative, absolute }); + } + + // Keep the strongest candidate per neighbourhood so one long stretch does not + // report the same jump at every offset. + const kept = []; + for (const candidate of candidates.sort((left, right) => right.relative - left.relative)) { + if (kept.some((other) => Math.abs(other.index - candidate.index) < window)) continue; + kept.push(candidate); + } + + kept.sort((left, right) => left.index - right.index); + kept.slice(0, 6).forEach((candidate, order) => { + const before = units.slice(candidate.index - window, candidate.index); + const after = units.slice(candidate.index, candidate.index + window); + const speakersBefore = unique(before.flatMap((unit) => unit.speakers ?? [])); + const speakersAfter = unique(after.flatMap((unit) => unit.speakers ?? [])); + const mixes = speakersBefore.length > 1 || speakersAfter.length > 1; + const claim = makeClaim( + `shifts.candidate.${order + 1}`, + `突变候选 ${order + 1}(消息长度)`, + `Shift candidate ${order + 1} (message length)`, + { + metric: "mean_chars", + at: units[candidate.index].anchor, + at_time: units[candidate.index].at === null ? null : new Date(units[candidate.index].at).toISOString(), + before: candidate.meanBefore, + after: candidate.meanAfter, + relative_change: round(candidate.relative), + absolute_change: round(candidate.absolute), + window, + // Whether the windows mix speakers: a jump measured across a change of + // who is talking says more about the participants than the mood. + pooled: mixes, + mixes_speakers: mixes, + speakers_before: speakersBefore, + speakers_after: speakersAfter, + }, + [...before.slice(-3).map((unit) => unit.anchor), ...after.slice(0, 3).map((unit) => unit.anchor)], + before.length + after.length, + units.map((unit) => unit.anchor), + ); + if (claim) claims.push(claim); + }); + + if (claims.length === 0) { + notes.push(note("没有超过双阈值的长度突变。", "No length jump cleared both shift thresholds.")); + } + return { claims, notes }; +} + +/* ------------------------------------------------------------------ */ +/* conflicts */ +/* ------------------------------------------------------------------ */ + +/** + * Stance contradictions, on two dimensions. + * + * sentiment how the person feels about something (挺好 / 很烦) + * certainty how sure they are (保证 / 可能还要再看) + * + * A pair qualifies only when both messages talk about the **same thing** — the + * shared substring is what makes it a contradiction rather than two unrelated + * opinions — and the two values sit on opposite sides. `same_speaker` separates + * "changed their mind" from "disagrees with someone", which are different + * findings and are reported as such. + */ +const SENTIMENT = { + positive: ["挺好", "不错", "没问题", "挺好的", "可以", "靠谱", "顺利", "满意", "支持"], + negative: ["很烦", "不行", "糟糕", "问题很大", "麻烦", "担心", "反对", "拖累", "不靠谱"], +}; +const CERTAINTY = { + sure: ["保证", "一定", "肯定", "确定", "必然", "绝对", "不会再"], + unsure: ["可能", "也许", "大概", "估计", "不确定", "再看", "说不准", "还要再"], +}; + +const polarityOf = (text, lexicon) => { + let score = 0; + for (const word of lexicon.positive ?? lexicon.sure ?? []) if (text.includes(word)) score += 1; + for (const word of lexicon.negative ?? lexicon.unsure ?? []) if (text.includes(word)) score -= 1; + return score === 0 ? 0 : Math.sign(score); +}; + +/** Longest shared run of non-punctuation characters, capped so it stays a topic. */ +function sharedTopic(left, right) { + const a = String(left ?? "").replace(/[\s,。!?、;:""''()《》…—,.!?;:()"']/g, ""); + const b = String(right ?? "").replace(/[\s,。!?、;:""''()《》…—,.!?;:()"']/g, ""); + let best = ""; + for (let start = 0; start < a.length; start += 1) { + for (let end = start + best.length + 1; end <= a.length; end += 1) { + const piece = a.slice(start, end); + if (!b.includes(piece)) break; + if (piece.length > best.length) best = piece; + } + } + return best.length >= 2 && best.length <= 8 ? best : null; +} + +export function deriveConflicts(corpus, helpers) { + const { units } = corpus; + const { makeClaim, note, MIN } = helpers; + const claims = []; + const notes = []; + + if (units.length < MIN.conflicts) { + notes.push(note(`矛盾样本不足:${units.length} < ${MIN.conflicts}。`, `Not enough messages for conflicts: ${units.length} < ${MIN.conflicts}.`)); + return { claims, notes }; + } + + const found = []; + const seen = new Set(); + for (let left = 0; left < units.length; left += 1) { + for (let right = left + 1; right < units.length; right += 1) { + const a = units[left]; + const b = units[right]; + const topic = sharedTopic(a.text, b.text); + if (topic === null) continue; + for (const [dimension, lexicon, positiveKey, negativeKey] of [ + ["sentiment", SENTIMENT, "positive", "negative"], + ["certainty", CERTAINTY, "sure", "unsure"], + ]) { + const polarityA = polarityOf(a.text, lexicon); + const polarityB = polarityOf(b.text, lexicon); + if (polarityA === 0 || polarityB === 0 || polarityA === polarityB) continue; + const positive = polarityA > 0 ? a : b; + const negative = polarityA > 0 ? b : a; + const key = `${dimension}:${topic}:${positive.anchor}:${negative.anchor}`; + if (seen.has(key)) continue; + seen.add(key); + found.push({ + dimension, + topic, + sameSpeaker: a.speaker !== null && a.speaker === b.speaker, + positive, + negative, + lexiconKeys: [positiveKey, negativeKey], + }); + } + } + } + + found.sort((left, right) => left.dimension.localeCompare(right.dimension) || left.topic.localeCompare(right.topic) || left.positive.anchor.localeCompare(right.positive.anchor)); + + // One claim per (dimension, topic, same/different speaker): the point is the + // contradiction, not how many sentence pairs happen to express it. + const grouped = new Map(); + for (const entry of found) { + const key = `${entry.dimension}|${entry.topic}|${entry.sameSpeaker}`; + if (!grouped.has(key)) grouped.set(key, entry); + } + + let index = 0; + for (const entry of [...grouped.values()].slice(0, 12)) { + index += 1; + const claim = makeClaim( + `conflicts.candidate.${String(index).padStart(4, "0")}`, + `矛盾候选 ${index}(${entry.dimension})`, + `Contradiction candidate ${index} (${entry.dimension})`, + { + dimension: entry.dimension, + topic: entry.topic, + same_speaker: entry.sameSpeaker, + speaker: entry.sameSpeaker ? entry.positive.speaker : null, + speakers: unique([entry.positive.speaker, entry.negative.speaker].filter((name) => name !== null)), + positive: { anchor: entry.positive.anchor, text: String(entry.positive.text).slice(0, 60) }, + negative: { anchor: entry.negative.anchor, text: String(entry.negative.text).slice(0, 60) }, + }, + [entry.positive.anchor, entry.negative.anchor], + 2, + ); + if (claim) claims.push({ ...claim, confidence: "low" }); + } + + if (claims.length === 0) { + notes.push(note("没有发现同一话题上的立场对立。", "No opposing stance on a shared topic was found.")); + } + return { claims, notes }; +} diff --git a/src/derive/fixtures/synthetic-group/README.md b/src/derive/fixtures/synthetic-group/README.md new file mode 100644 index 00000000..b90336f7 --- /dev/null +++ b/src/derive/fixtures/synthetic-group/README.md @@ -0,0 +1,29 @@ +# `synthetic-group` — retrospect 合成账本夹具 + +**不是真人对话,也不是真实导出。** 全部内容为本任务原创,随仓库以 MIT 发布, +只用来给 `tests/retrospect.test.mjs` 提供一份"形状正确、特征已知"的账本。 + +## 它必须能触发的特征 + +| 特征 | 在哪里 | 用途 | +| --- | --- | --- | +| 4 个说话人 | 老周 / 小陈 / 林工 / 阿May | `stats.participant_*`、`relations.*` | +| 71 条消息 | `group-chat.md` 44 + `dm-lin-chen.md` 26 + `incident-postmortem.md` 1 | 各维度的样本量 | +| 语调/长度突变 | `k0001:t21`–`k0001:t34`(故障期间消息明显变长、带感叹号) | `shifts.*` | +| 话题回避 | `k0001:t18`→`t19`(被问 offer,一句"先不说这个"转开)、`k0001:t31`→`t32`("这个不方便说") | `boundaries.*` | +| 同一维度取值相反 | `k0001:t5`("远程办公挺好的")vs `k0001:t38`("远程办公其实很烦");`k0001:t9`("这个方案不行")vs `k0001:t12`("这个方案我觉得没问题");`k0001:t29`("保证不会再有第二次")vs `k0001:t33`("可能还要再看两天") | `conflicts.*`(褒贬 + 确定程度两个维度) | +| 称呼变化 | `k0002` 前半段"林工"、后半段"林哥" | `relations.address_*` | +| 锚点两种粒度 | `k0001:tN` / `k0002:tN`(轮次级)与 `k0003`(段落级) | 锚点解析与回指 | +| 表情 | `k0002:t22` 🙂、`k0002:t24` 👍 | `voice.emoji_density` | +| 时间戳 | 每行正文内联 ISO 8601 | `stats.time_*`、`timeline.*` | + +## 形状 + +``` +synthetic-group/ + knowledge/index.json # 数组,3 个条目,每条带 anchors(字符串数组) + knowledge/text/*.md # 正文,行首锚点 [k0001:t1] / [k0003] +``` + +`index.json` 里的 `sha256` 是同一目录下 `.md` 文件的真实摘要; +`tests/retrospect.test.mjs` 会重新计算并断言一致,防止夹具被改坏。 diff --git a/src/derive/fixtures/synthetic-group/knowledge/index.json b/src/derive/fixtures/synthetic-group/knowledge/index.json new file mode 100644 index 00000000..2b2516db --- /dev/null +++ b/src/derive/fixtures/synthetic-group/knowledge/index.json @@ -0,0 +1,114 @@ +[ + { + "id": "k-src-2", + "kind": "messages", + "origin": "feishu/dm-lin-chen-export.json", + "fetched_at": "2024-03-21T00:00:00Z", + "bytes": 2028, + "sha256": "8804b43b5e6d08a0f1e12d10468218396440d7acdd7e1f1dafa7bcc9c30e0786", + "credentialed": false, + "method": "parse-chat", + "warnings": [], + "anchors": [ + "k0002:t1", + "k0002:t2", + "k0002:t3", + "k0002:t4", + "k0002:t5", + "k0002:t6", + "k0002:t7", + "k0002:t8", + "k0002:t9", + "k0002:t10", + "k0002:t11", + "k0002:t12", + "k0002:t13", + "k0002:t14", + "k0002:t15", + "k0002:t16", + "k0002:t17", + "k0002:t18", + "k0002:t19", + "k0002:t20", + "k0002:t21", + "k0002:t22", + "k0002:t23", + "k0002:t24", + "k0002:t25", + "k0002:t26" + ] + }, + { + "id": "k-src-1", + "kind": "messages", + "origin": "feishu/group-chat-export.json", + "fetched_at": "2024-03-21T00:00:00Z", + "bytes": 4473, + "sha256": "cafe7e613c17dca21f4bec05ecb61aacb4626f30e4e675e5560681f6cbad2c33", + "credentialed": false, + "method": "parse-chat", + "warnings": [], + "anchors": [ + "k0001:t1", + "k0001:t2", + "k0001:t3", + "k0001:t4", + "k0001:t5", + "k0001:t6", + "k0001:t7", + "k0001:t8", + "k0001:t9", + "k0001:t10", + "k0001:t11", + "k0001:t12", + "k0001:t13", + "k0001:t14", + "k0001:t15", + "k0001:t16", + "k0001:t17", + "k0001:t18", + "k0001:t19", + "k0001:t20", + "k0001:t21", + "k0001:t22", + "k0001:t23", + "k0001:t24", + "k0001:t25", + "k0001:t26", + "k0001:t27", + "k0001:t28", + "k0001:t29", + "k0001:t30", + "k0001:t31", + "k0001:t32", + "k0001:t33", + "k0001:t34", + "k0001:t35", + "k0001:t36", + "k0001:t37", + "k0001:t38", + "k0001:t39", + "k0001:t40", + "k0001:t41", + "k0001:t42", + "k0001:t43", + "k0001:t44" + ] + }, + { + "id": "k-src-3", + "kind": "docs", + "origin": "notes/incident-postmortem.md", + "fetched_at": "2024-03-21T00:00:00Z", + "bytes": 421, + "sha256": "378da8f92728ed27ebf151f68a9bbee1bc9c405da431d6cd458d96ea806b0e4d", + "credentialed": false, + "method": "harvest", + "warnings": [ + "正文由本地 Markdown 归一化,未保留原始富文本样式" + ], + "anchors": [ + "k0003" + ] + } +] diff --git a/src/derive/fixtures/synthetic-group/knowledge/text/dm-lin-chen.md b/src/derive/fixtures/synthetic-group/knowledge/text/dm-lin-chen.md new file mode 100644 index 00000000..bcbf93d7 --- /dev/null +++ b/src/derive/fixtures/synthetic-group/knowledge/text/dm-lin-chen.md @@ -0,0 +1,26 @@ +[k0002:t1] 2024-03-14T19:02:00Z 小陈:林工,白天那个冲正我还有点没懂。 +[k0002:t2] 2024-03-14T19:05:00Z 林工:哪一段? +[k0002:t3] 2024-03-14T19:06:00Z 小陈:就是为什么要先停任务再对账。 +[k0002:t4] 2024-03-14T19:09:00Z 林工:因为任务还在跑,你对着对着数就变了,先止损再排查。 +[k0002:t5] 2024-03-14T19:12:00Z 小陈:懂了,先止损。谢谢林工。 +[k0002:t6] 2024-03-14T19:15:00Z 林工:不客气。 +[k0002:t7] 2024-03-14T19:20:00Z 小陈:林工,客服那边想要一个对外说法。 +[k0002:t8] 2024-03-14T19:23:00Z 林工:就说系统对账延迟,已经修复,不涉及用户资金。 +[k0002:t9] 2024-03-14T19:26:00Z 小陈:好,我按这个写。 +[k0002:t10] 2024-03-14T19:30:00Z 小陈:林工,复盘里那句"没人敢动"要不要删掉? +[k0002:t11] 2024-03-14T19:33:00Z 林工:不用删,事实就是事实。 +[k0002:t12] 2024-03-14T19:36:00Z 小陈:行,那我保留。 +[k0002:t13] 2024-03-14T19:40:00Z 林工:嗯。 +[k0002:t14] 2024-03-15T20:01:00Z 小陈:林哥,周末还看消息啊? +[k0002:t15] 2024-03-15T20:04:00Z 林工:习惯了,看一眼心里踏实。 +[k0002:t16] 2024-03-15T20:07:00Z 小陈:林哥,你觉得这次最大的教训是什么? +[k0002:t17] 2024-03-15T20:10:00Z 林工:没有幂等键就敢重跑,这是设计问题,不是运气问题。 +[k0002:t18] 2024-03-15T20:13:00Z 小陈:记下了。还有别的吗? +[k0002:t19] 2024-03-15T20:16:00Z 林工:告警太吵,真的出事反而没人看,宁可少一点、准一点。 +[k0002:t20] 2024-03-15T20:19:00Z 小陈:这个我也写进去。 +[k0002:t21] 2024-03-15T20:22:00Z 林工:写吧。 +[k0002:t22] 2024-03-15T20:25:00Z 小陈:林哥,下周评审你来讲这段?🙂 +[k0002:t23] 2024-03-15T20:28:00Z 林工:可以,我来讲。 +[k0002:t24] 2024-03-15T20:31:00Z 小陈:太好了👍 +[k0002:t25] 2024-03-15T20:34:00Z 林工:你把材料发我。 +[k0002:t26] 2024-03-15T20:37:00Z 小陈:马上发。 diff --git a/src/derive/fixtures/synthetic-group/knowledge/text/group-chat.md b/src/derive/fixtures/synthetic-group/knowledge/text/group-chat.md new file mode 100644 index 00000000..330c4cd6 --- /dev/null +++ b/src/derive/fixtures/synthetic-group/knowledge/text/group-chat.md @@ -0,0 +1,44 @@ +[k0001:t1] 2024-03-04T09:02:00Z 老周:早,今天先把灰度方案定下来。 +[k0001:t2] 2024-03-04T09:05:00Z 小陈:收到,我十点前把指标对齐一下。 +[k0001:t3] 2024-03-04T09:07:00Z 林工:先看数据。昨天的对账差异我拉了清单。 +[k0001:t4] 2024-03-04T09:11:00Z 阿May:我这边设计稿切好了,等方案。 +[k0001:t5] 2024-03-04T09:14:00Z 林工:远程办公挺好的,效率高,上午能专心写代码。 +[k0001:t6] 2024-03-04T09:20:00Z 小陈:你这边能出个排期吗? +[k0001:t7] 2024-03-04T09:26:00Z 林工:能,今天下班前给。 +[k0001:t8] 2024-03-04T14:30:00Z 小陈:排期对齐一下,我先按两周估。 +[k0001:t9] 2024-03-04T14:35:00Z 老周:这个方案不行,风险太大,灰度先切小。 +[k0001:t10] 2024-03-04T14:40:00Z 林工:风险就一个,老链路的补偿逻辑没人敢动。 +[k0001:t11] 2024-03-05T10:00:00Z 阿May:我这边把空状态补上,今天给。 +[k0001:t12] 2024-03-05T10:06:00Z 小陈:好的,这个方案我觉得没问题,对齐一下验收标准。 +[k0001:t13] 2024-03-05T10:12:00Z 林工:验收标准就一条,账能对上。 +[k0001:t14] 2024-03-05T10:20:00Z 老周:各位,别忘了周五的复盘。 +[k0001:t15] 2024-03-05T16:40:00Z 小陈:老师们的意见我都记下了。 +[k0001:t16] 2024-03-06T09:15:00Z 阿May:我这边改完了,你那边看看? +[k0001:t17] 2024-03-06T09:22:00Z 林工:看了,可以。 +[k0001:t18] 2024-03-08T10:20:00Z 小陈:对了林工,你上次说的那个offer最后怎么定的? +[k0001:t19] 2024-03-08T10:24:00Z 林工:嗯,先不说这个,把灰度方案过一遍。 +[k0001:t20] 2024-03-08T10:30:00Z 老周:对,先过方案。 +[k0001:t21] 2024-03-11T02:10:00Z 老周:告警了,出入金对账差异超过阈值了,谁在看?我先拉一下监控面板。 +[k0001:t22] 2024-03-11T02:12:00Z 林工:我在看。先别改代码,把账翻出来,一条一条对,数据不会骗人,代码是你写的,你会替它辩护。 +[k0001:t23] 2024-03-11T02:15:00Z 林工:初步判断是补偿任务重跑了,同一笔入金被记了两次,我先把任务停掉,再写个脚本把重复的捞出来,一条一条对清楚。 +[k0001:t24] 2024-03-11T02:18:00Z 小陈:需要我通知客服吗?我这边可以先准备一套话术。 +[k0001:t25] 2024-03-11T02:20:00Z 林工:先不用,等我把范围圈出来再说,现在通知只会让客服被问爆,反而更乱,等圈定了范围我们再统一口径。 +[k0001:t26] 2024-03-11T02:26:00Z 阿May:我在线,需要改文案我随时上,空状态和提示语我都留了位置。 +[k0001:t27] 2024-03-11T02:31:00Z 林工:影响面确认了,重复入账一万三千笔,涉及四千二百个用户,明细我已经导出来放在共享盘里,谁要都能看。 +[k0001:t28] 2024-03-11T02:40:00Z 老周:干得漂亮,先把钱对上,别急着发公告,公告要等范围完全确定之后再发。 +[k0001:t29] 2024-03-11T02:52:00Z 林工:钱对上了,重复的部分我做了一笔冲正,账已经平了,接下来给补偿任务加幂等键,保证不会再有第二次。 +[k0001:t30] 2024-03-11T03:05:00Z 小陈:太好了!我这边同步一下客服口径!有问题我随时喊你! +[k0001:t31] 2024-03-11T14:02:00Z 小陈:那offer的事... +[k0001:t32] 2024-03-11T14:05:00Z 林工:这个不方便说,先聊排期。 +[k0001:t33] 2024-03-12T10:00:00Z 林工:幂等键上线了,今天观察一天。不过可能还要再看两天,现在说闭环有点早,我会盯着监控。 +[k0001:t34] 2024-03-12T10:08:00Z 老周:好,写个复盘,把根因和动作都记下来,周五我们一起过一遍。 +[k0001:t35] 2024-03-18T09:30:00Z 林工:复盘写完了,根因是补偿任务没有幂等键。 +[k0001:t36] 2024-03-18T09:35:00Z 小陈:我这边同步一下。 +[k0001:t37] 2024-03-18T09:40:00Z 阿May:我这边加个提示。 +[k0001:t38] 2024-03-18T09:45:00Z 林工:远程办公其实很烦,沟通成本太高,出事全靠群里刷屏。 +[k0001:t39] 2024-03-18T09:50:00Z 老周:各有各的好。 +[k0001:t40] 2024-03-19T11:00:00Z 小陈:下周的评审我拉个会? +[k0001:t41] 2024-03-19T11:05:00Z 林工:可以。 +[k0001:t42] 2024-03-19T11:10:00Z 阿May:我这边没问题。 +[k0001:t43] 2024-03-20T15:00:00Z 老周:各位,这个季度就到这,辛苦了。 +[k0001:t44] 2024-03-20T15:10:00Z 小陈:对齐一下,下周见。 diff --git a/src/derive/fixtures/synthetic-group/knowledge/text/incident-postmortem.md b/src/derive/fixtures/synthetic-group/knowledge/text/incident-postmortem.md new file mode 100644 index 00000000..d82f5986 --- /dev/null +++ b/src/derive/fixtures/synthetic-group/knowledge/text/incident-postmortem.md @@ -0,0 +1 @@ +[k0003] 2024-03-13T09:00:00Z 林工:事故复盘(草稿)。时间线:02:10 告警,02:12 介入,02:52 资金侧对平,次日 10:00 幂等键上线。根因:补偿任务重跑时没有幂等键,同一笔入金被重复记账。动作:给补偿任务补幂等键;把对账差异纳入每日巡检;把重跑改成需要人工确认。遗留:老链路的补偿逻辑还没有人完整读过一遍。 From 73398bcace2026b8fd62015f472d919e1a8aad9e Mon Sep 17 00:00:00 2001 From: dsh-agent Date: Tue, 15 Sep 2026 22:58:46 +0800 Subject: [PATCH 7/7] =?UTF-8?q?ci:=20=E8=A7=A6=E5=8F=91=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E5=8A=A0=20ds/**=EF=BC=88=E6=9C=AC=E5=88=86=E6=94=AF=E6=AD=A4?= =?UTF-8?q?=E5=89=8D=E6=B0=B8=E8=BF=9C=E8=B7=91=E4=B8=8D=E5=88=B0=20CI?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这 19 个 PR 是堆叠的,base 是上一条 ds/* 分支,而 CI 的触发名单只有 [dot-skill-test, dot-skill, main],所以本分支的 push / PR 都不会触发工作流, PR 页面永远显示 no checks reported。这里只改触发条件,不动任何产品代码。 --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 876f980b..586fd51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,13 @@ name: CI on: + # The per-feature PRs are a stack whose bases are other `ds/*` branches; with the + # trigger list limited to the integration branches, none of them could ever run CI. push: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] pull_request: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] + workflow_dispatch: jobs: test: