325 lines
12 KiB
Go
325 lines
12 KiB
Go
// 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)
|
||
}
|