textmachine/eval/pkg7/termharness/main.go

325 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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 печатает `исходная_строка<TAB>ключ` — единственный способ сшивать множества сида, майнера и
// банкноты ТЕМ ЖЕ ключом, что использует движок (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)
}