// Command termharness собирает запрос БОЕВОЙ роли терминолога (`internal/terminology` + // `pipeline.MessagesWithInjection`) вне репозитория — как это делает `minerharness` для майнера. // // ЗАЧЕМ ИМЕННО ТАК (пакет-8, замеры 2 и 3). Промт роли — не единственное, что видит модель: между // системным промтом и списком термов боевой код кладёт якорь ⟦TM-CANON⟧, а сам список рендерит // `terminology.RenderBatch` с фиксированным набором полей (`key/type/origin/freq/since_ch/aliases/ // related/evidence/drafts/ctx`). Реплика этого на Python в пакете-7 была бы третьей копией формата и // первым же кандидатом на расхождение с продом — а именно расхождение сборки запроса и было ошибкой // фазы B («фактор, который есть во всех армах, не измеряется ни одним»). Здесь запрос собирает тот же // код, что в проде; полигон отвечает только за сетку параметров и за отправку. // // Пин обязателен по той же причине, что и в minerharness: `replace` смотрит в копию коммита // (`git archive`), а не в рабочее дерево. // // Режимы: // // -mode build — собрать батчи запросов (JSON: system/canon/user + ключи батча) // -mode norm — нормализовать ключи построчно тем же `text.NormalizeSourceKey`, что и прод // -mode parse — разобрать ответ модели боевым `terminology.ParseReply` // // Ноль сетевых вызовов: харнесс НИЧЕГО не отправляет. Отправка и деньги — на стороне Python-драйвера. package main import ( "bufio" "encoding/json" "flag" "fmt" "os" "sort" "strings" "textmachine/backend/internal/config" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/terminology" "textmachine/backend/internal/text" ) // chunkRow — субстрат `eval/pkg7/split_book.py`, тот же, что ест minerharness. type chunkRow struct { Chapter int `json:"chapter"` ChunkIdx int `json:"chunk_idx"` Source string `json:"source"` } // candRow — вход WHICH-канала (майнер или произвольный список кандидатов для пробы отказов). type candRow struct { Src string `json:"src"` Type string `json:"type"` Freq int `json:"freq"` SinceCh int `json:"since_ch"` Aliases []string `json:"aliases"` Evidence []string `json:"evidence"` } // obsRow — вход WHAT-канала (банкнота), уже свёрнутый по (key, dst), как это делает bankObservedByKey. type obsRow struct { Key string `json:"key"` Src string `json:"src"` Type string `json:"type"` Proposals []struct { Dst string `json:"dst"` Type string `json:"type"` Chunks int `json:"chunks"` } `json:"proposals"` } type batchOut struct { Index int `json:"index"` Keys []string `json:"keys"` Srcs []string `json:"srcs"` Messages []map[string]any `json:"messages"` Cands []map[string]any `json:"cands"` Sizes map[string]int `json:"sizes"` Variants map[string][]string `json:"variants"` } func main() { var ( mode = flag.String("mode", "build", "build | norm | parse") chunksP = flag.String("chunks", "", "JSONL чанков (chapter, chunk_idx, source)") candsP = flag.String("cands", "", "JSON кандидатов WHICH-канала") obsP = flag.String("observed", "", "JSON наблюдений банкноты (опционально)") canonP = flag.String("canon", "", "JSON подписанных пар [[src,dst],…] (опционально)") bookP = flag.String("book", "", "book.yaml") promptP = flag.String("prompt", "", "промт роли (terminologist.md)") kwicPer = flag.Int("kwic-per", 3, "контекстов на терм") kwicWidth = flag.Int("kwic-width", 40, "ширина контекста") batchRunes = flag.Int("batch-runes", 6000, "рун в одном батче") canonCap = flag.Int("canon-cap", 40, "потолок строк якоря ⟦TM-CANON⟧") replyP = flag.String("reply", "", "файл ответа модели (для -mode parse)") keysP = flag.String("keys", "", "JSON ожидаемых ключей (для -mode parse)") outP = flag.String("out", "", "куда писать JSON (пусто = stdout)") ) flag.Parse() switch *mode { case "norm": runNorm() case "parse": runParse(*replyP, *keysP, *outP) case "build": runBuild(*chunksP, *candsP, *obsP, *canonP, *bookP, *promptP, *kwicPer, *kwicWidth, *batchRunes, *canonCap, *outP) default: fatal(fmt.Errorf("неизвестный режим %q", *mode)) } } // runNorm печатает `исходная_строкаключ` — единственный способ сшивать множества сида, майнера и // банкноты ТЕМ ЖЕ ключом, что использует движок (trad→simp + NFKC + katakana→hiragana + lower). func runNorm() { w := bufio.NewWriter(os.Stdout) defer w.Flush() sc := bufio.NewScanner(os.Stdin) sc.Buffer(make([]byte, 1<<20), 1<<20) for sc.Scan() { s := strings.TrimRight(sc.Text(), "\r\n") if s == "" { continue } fmt.Fprintf(w, "%s\t%s\n", s, text.NormalizeSourceKey(s)) } if err := sc.Err(); err != nil { fatal(err) } } func runParse(replyPath, keysPath, outPath string) { if replyPath == "" || keysPath == "" { fatal(fmt.Errorf("-mode parse требует -reply и -keys")) } reply, err := os.ReadFile(replyPath) if err != nil { fatal(err) } var keys []string readJSON(keysPath, &keys) got, bad := terminology.ParseReply(string(reply), keys, text.NormalizeSourceKey) // Разделяем ТРИ исхода, которые прод считает по-разному (terminologist.go:269-279): ответ с // переводом, явный отказ ⟦TM-NO-DST⟧ (пустая строка в карте) и молчание (ключа в карте нет). declined, answered := []string{}, map[string]string{} for k, v := range got { if v == "" { declined = append(declined, k) continue } answered[k] = v } unanswered := []string{} for _, k := range keys { if _, ok := got[k]; !ok { unanswered = append(unanswered, k) } } sort.Strings(declined) sort.Strings(unanswered) writeJSON(outPath, map[string]any{ "answered": answered, "declined": declined, "unanswered": unanswered, "bad_lines": bad, }) } func runBuild(chunksPath, candsPath, obsPath, canonPath, bookPath, promptPath string, kwicPer, kwicWidth, batchRunes, canonCap int, outPath string) { if chunksPath == "" || candsPath == "" || bookPath == "" || promptPath == "" { fatal(fmt.Errorf("-mode build требует -chunks -cands -book -prompt")) } book, err := config.LoadBook(bookPath) if err != nil { fatal(err) } tpl, err := pipeline.LoadPromptTemplate(promptPath) if err != nil { fatal(err) } // Субстрат KWIC — тот же, что у майнера: нормализованный текст чанка. var chunks []terminology.Chunk f, err := os.Open(chunksPath) if err != nil { fatal(err) } sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 1<<22), 1<<22) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } var r chunkRow if err := json.Unmarshal([]byte(line), &r); err != nil { fatal(err) } chunks = append(chunks, terminology.Chunk{ Chapter: r.Chapter, ChunkIdx: r.ChunkIdx, NSource: text.NormalizeSourceKey(r.Source), }) } f.Close() if err := sc.Err(); err != nil { fatal(err) } var cr []candRow readJSON(candsPath, &cr) mined := make([]terminology.Mined, 0, len(cr)) for _, c := range cr { mined = append(mined, terminology.Mined{ Key: text.NormalizeSourceKey(c.Src), Src: c.Src, Type: c.Type, Freq: c.Freq, SinceCh: c.SinceCh, Aliases: c.Aliases, Evidence: c.Evidence, }) } var observed []terminology.Observed if obsPath != "" { var or []obsRow readJSON(obsPath, &or) for _, o := range or { key := o.Key if key == "" { key = text.NormalizeSourceKey(o.Src) } ps := make([]terminology.Proposal, 0, len(o.Proposals)) for _, p := range o.Proposals { ps = append(ps, terminology.Proposal{Dst: p.Dst, Type: p.Type, Chunks: p.Chunks}) } observed = append(observed, terminology.Observed{Key: key, Src: o.Src, Type: o.Type, Proposals: ps}) } } var canon []terminology.Neighbour if canonPath != "" { var pairs [][2]string readJSON(canonPath, &pairs) for _, p := range pairs { canon = append(canon, terminology.Neighbour{Src: text.NormalizeSourceKey(p[0]), Dst: p[1]}) } sort.Slice(canon, func(i, j int) bool { return canon[i].Src < canon[j].Src }) } cands := terminology.Merge(mined, observed) // kwic-per 0 = арм БЕЗ контекстов (обязательный арм-без-фактора D39.46). Прод такой конфиг // подменил бы дефолтом (terminologyOpts), поэтому здесь AttachKWIC просто не вызывается — это // ЕДИНСТВЕННОЕ сознательное отклонение от боевой сборки, и оно помечено в отчёте. if kwicPer > 0 && kwicWidth > 0 { cands = terminology.AttachKWIC(cands, chunks, kwicPer, kwicWidth) } else { cands = terminology.AttachKWIC(cands, chunks, 0, 0) // проставит Freq/Occurrences, контексты пустые } opts := terminology.ScoreOpts{Neighbours: canon} for i := range cands { terminology.ScoreVariants(&cands[i], opts) } batches := terminology.Batch(cands, batchRunes) out := make([]batchOut, 0, len(batches)) for i, b := range batches { msgs, err := pipeline.MessagesWithInjection(tpl, pipeline.RenderVars{Book: book, Text: terminology.RenderBatch(b)}, terminology.RenderCanonAnchor(terminology.CanonFor(b, canon, canonCap))) if err != nil { fatal(err) } mm := make([]map[string]any, 0, len(msgs)) for _, m := range msgs { mm = append(mm, map[string]any{"role": m.Role, "content": m.Content}) } keys, srcs := make([]string, 0, len(b)), make([]string, 0, len(b)) cinfo := make([]map[string]any, 0, len(b)) vars := map[string][]string{} for _, c := range b { keys = append(keys, c.Key) srcs = append(srcs, c.Src) cinfo = append(cinfo, map[string]any{ "key": c.Key, "src": c.Src, "type": c.Type, "origin": string(c.Origin), "freq": c.Freq, "kwic": len(c.KWIC), "spread": c.Spread(), "best": c.Best(), }) for _, v := range c.Variants { vars[c.Key] = append(vars[c.Key], v.Dst) } } nchars := 0 for _, m := range msgs { nchars += len([]rune(m.Content)) } out = append(out, batchOut{ Index: i, Keys: keys, Srcs: srcs, Messages: mm, Cands: cinfo, Sizes: map[string]int{"runes": nchars, "terms": len(b)}, Variants: vars, }) } writeJSON(outPath, map[string]any{ "batches": out, "candidates": len(cands), "kwic_per": kwicPer, "kwic_width": kwicWidth, "batch_runes": batchRunes, "version": "terminology-v1-merge+kwic+c2-3", }) } func readJSON(path string, v any) { b, err := os.ReadFile(path) if err != nil { fatal(err) } if err := json.Unmarshal(b, v); err != nil { fatal(fmt.Errorf("%s: %w", path, err)) } } func writeJSON(path string, v any) { b, err := json.MarshalIndent(v, "", " ") if err != nil { fatal(err) } if path == "" { os.Stdout.Write(append(b, '\n')) return } if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil { fatal(err) } fmt.Fprintf(os.Stderr, "написано: %s\n", path) } func fatal(err error) { fmt.Fprintln(os.Stderr, "termharness:", err) os.Exit(1) }