Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3f1d0177d |
@@ -47,6 +47,11 @@
|
||||
|
||||
---
|
||||
|
||||
## 下一版(未發佈)
|
||||
|
||||
- 🔴 **「已送上去」從今以後是真的送上去了**:以前只要雲端把請求收下來,小幫手就算它成功——就算雲端其實根本沒把它寫進你的知識庫。畫面上是綠的、數字也在跑,而 AI 一句都查不到,而且那些檔案因為被蓋了「已送」的章,**永遠不會再試一次**。現在它會看雲端真正的回覆:沒寫進去就誠實標成失敗、不蓋章、下一輪重新送。(實測:一個開發資料夾 26 份標「已送達」,雲端實際只有 4 份。)
|
||||
- **太大的檔案現在會好好跟你說**:以前十幾萬字的大檔會一直送、一直失敗,而你看到的是一串沒人讀得懂的錯誤訊息,還會把每天的免費額度燒掉。現在它會先量一下,太大就不送,並且告訴你「這份檔約幾萬字、拆小一點就會自動收進來」。
|
||||
|
||||
## 0.18.38(2026-08-26)
|
||||
|
||||
- 🔴 **每一層子資料夾都看得到,不是只有第一層**:以前你只看得到自己選的那個資料夾本身,裡面到底有哪些子資料夾、哪幾層、哪些東西真的上去了,完全看不到。現在**桌面小幫手與知識庫網頁兩邊都攤得開**:小幫手裡點進那個知識庫,資料夾右邊多一顆「資料夾結構」,按下去就是一棵像檔案總管那樣、一列一列往下縮排的樹,點資料夾展開、再點收合,也可以一鍵全部展開/全部收合。
|
||||
|
||||
@@ -521,10 +521,25 @@ func (c *DirectConfig) postJSON(url string, body any) (int, string, error) {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
// 🔴 讀 64KB 而不是 1KB:觸發端點的回應是一層外殼包著工作流的輸出,
|
||||
// 而**失敗的證據住在殼裡面**(見 triggeroutcome.go)。1KB 會把 JSON 切斷 ⇒
|
||||
// 永遠解析不了 ⇒ 每一次失敗都被讀成「看不出來」⇒ 下面那道閘等於不存在。
|
||||
full, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
snippet := full
|
||||
if len(snippet) > 1024 {
|
||||
snippet = snippet[:1024]
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp.StatusCode, string(snippet), fmt.Errorf("HTTP %d:%s", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||||
}
|
||||
// 🔴 2xx 只證明「請求送到了」,不證明「東西寫進知識庫了」。
|
||||
// named-webhook 觸發成功一律回 200,工作流內部失敗藏在 body 裡
|
||||
//(2026-08-26 實測 `InkStoneCo`:26 份蓋了「已送達」章,雲端只有 4 份)。
|
||||
// 判斷放在這裡而不是各呼叫端:**「忘了接」這個失敗模式不該存在**
|
||||
//(同 ingestplan.go 把兩張排除表收成一張的理由)。
|
||||
if msg := webhookFailure(string(full)); msg != "" {
|
||||
return resp.StatusCode, string(snippet), errors.New(msg)
|
||||
}
|
||||
return resp.StatusCode, string(snippet), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,46 @@ import (
|
||||
// (選型實測 llama-4-scout 約 2.4s,但長文+冷啟動要留餘裕。)
|
||||
var workersAIHTTP = &http.Client{Timeout: 90 * time.Second}
|
||||
|
||||
// maxWorkersAIExtractBytes=送進雲端萃取的純文字上限(位元組)。
|
||||
//
|
||||
// 🔴 為什麼要有這道閘(2026-08-26 實測 `InkStoneCo`,非推測):
|
||||
// 這條路沒有任何長度判斷——整份原稿原封不動塞進 prompt。leo 的
|
||||
// `system-dev/wiki/mistakes.md`(562 KB)、`status.md`(401 KB)、
|
||||
// `status-archive-2026-08.md`(511 KB)因此每一輪都撞同一面牆,
|
||||
// 而使用者看到的是這串**沒有人讀得懂的東西**:
|
||||
//
|
||||
// 本地萃取失敗:雲端萃取失敗(HTTP 502):Workers AI 執行失敗:8007:
|
||||
// {"error":{"message":"This model's maximum context length is 131000 tokens…
|
||||
//
|
||||
// ⇒ 兩個錯:①明知一定會失敗還是送出去(每次燒一份額度、拖住整個佇列)
|
||||
// ②失敗的理由沒有翻成人話(#104 的紅線:不要讓他猜)。
|
||||
//
|
||||
// 300,000 這個數字怎麼來的:實測那份 401,526 位元組的檔,上游回報
|
||||
// 「prompt contains at least 122,xxx tokens」,而可用輸入是
|
||||
// 131,000 − 8,192(輸出) ≈ 122,800 ⇒ 中文原稿約 3.3 位元組/token。
|
||||
// 300 KB ≈ 9 萬 token,留了三成餘裕給提示詞本身與英數混排的變異。
|
||||
//
|
||||
// ⚠️ 這個上限只綁**這條路**(雲端 llama-4-scout 的 131k 視窗)。
|
||||
// gemma 路走 Gemini、視窗大一個數量級,不受此限——判準跟著模型走,
|
||||
// 不做成全域常數,免得換模型時有人以為它是產品規格。
|
||||
const maxWorkersAIExtractBytes = 300_000
|
||||
|
||||
// tooBigForWorkersAI 回傳「這份原稿太大,這條路讀不完」的人話理由;
|
||||
// 沒超過回空字串。
|
||||
//
|
||||
// 🔴 訊息是產品文案不是 debug 字串:要講**多大**、**為什麼不收**、**他能做什麼**,
|
||||
// 而且不准出現狀態碼、模型名或 token 這種只有工程師看得懂的詞。
|
||||
func tooBigForWorkersAI(srcText, relPath string) string {
|
||||
if len(srcText) <= maxWorkersAIExtractBytes {
|
||||
return ""
|
||||
}
|
||||
// 中文一個字約 3 位元組——換算成「字數」才是使用者對得上的單位。
|
||||
wan := len(srcText) / 3 / 10000
|
||||
return fmt.Sprintf(
|
||||
"這份檔太大了(約 %d 萬字),雲端的整理模型一次讀不完,所以這次沒有收它。"+
|
||||
"把它拆成幾份小一點的檔就會自動收進來。", wan)
|
||||
}
|
||||
|
||||
// ExtractWithWorkersAI 讀原稿 → 送自己雲端的 /portal/daemon/extract 萃卡 → 卡片落地。
|
||||
// cypherURL/apiKey 用的是 daemon 既有的連線憑證(送卡片上雲時同一把,見 direct.go)。
|
||||
// 回傳產出的卡片相對路徑(單檔一卡),與 ExtractWithGemma 契約一致。
|
||||
@@ -74,6 +114,12 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
|
||||
return nil, fmt.Errorf("轉檔失敗(%s):%w", relPath, err)
|
||||
}
|
||||
|
||||
// 🔴 明知送出去一定會失敗,就不要送(見 maxWorkersAIExtractBytes)。
|
||||
// 早一步擋下=不燒額度、不占佇列,而且使用者看到的是人話不是上游錯誤碼。
|
||||
if why := tooBigForWorkersAI(srcText, relPath); why != "" {
|
||||
return nil, fmt.Errorf("%s", why)
|
||||
}
|
||||
|
||||
pageName := pageNameOf(relPath)
|
||||
// #134:prompt=與 gemma 路同一份契約(同 package 同函式,物理上不可能漂移)。
|
||||
// page_name/text 仍照送:舊雲端不認得 prompt,會拿它們組 legacy 提示詞回舊卡。
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// triggeroutcome.go — 「雲端收下了」不等於「知識庫寫進去了」(`inkstone/arcrun-rag#104`)。
|
||||
//
|
||||
// 🔴 為什麼有這支檔(2026-08-26 實測,非推測):
|
||||
// 把 leo 真實的 `InkStoneCo` 接上 youlin 跑一輪,daemon 對 26 份檔案蓋了「已送達」章,
|
||||
// **雲端實際只有 4 份**。使用者畫面上是綠的,AR-Mira 一句都查不到。
|
||||
//
|
||||
// 真兇不是萃取、不是排除規則,是這一行:`postJSON` 只看 HTTP 狀態碼。
|
||||
// named-webhook 觸發成功一律回 **200**,而工作流內部有沒有把東西寫進 KBDB
|
||||
// 藏在 body 裡。當天的實際回應(原文照抄):
|
||||
//
|
||||
// HTTP 200
|
||||
// {"success":true,"data":{"success":false,"status":500,
|
||||
// "error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}
|
||||
//
|
||||
// ⇒ 外層說 success,內層說 500/unreachable。daemon 讀外層 ⇒ 蓋章 ⇒
|
||||
// content_hash 沒變就永遠不會重送 ⇒ **這份知識永久消失,而且沒有人會知道**。
|
||||
//
|
||||
// 這與 `Arcrun#135`(push_workflow 對跑不起來的定義回「部署成功!」)是同一個病:
|
||||
// **回報層與事實層分居兩處,而讀的人只讀得到回報層。**
|
||||
// 我們改不了別人回什麼,但可以改「我們信什麼」——2xx 只證明請求送達,
|
||||
// 要證明寫進去了,得看 body。
|
||||
//
|
||||
// 三條自我約束:
|
||||
// - **只在看得懂的時候才判失敗**:body 不是 JSON、或沒有任何 success 欄位
|
||||
// ⇒ 回「看不出來」=照舊當成功。寧可漏判,不可把一次格式變更變成全面停擺。
|
||||
// - **不猜語意**:只認 `success:false` 這個明確訊號,不去猜 data 裡別的欄位。
|
||||
// - **講人話**:訊息會出現在使用者畫面上,不裸露狀態碼與上游 JSON 原文
|
||||
// (同 direct_quota_test.go 那份禁字表)。
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// triggerEnvelope=named-webhook 觸發端點的回應外殼。
|
||||
// `Data` 用 json.RawMessage:它可能是物件、陣列、字串,甚至 null——
|
||||
// 硬綁成 map 會在形狀一變時整條路 panic 或誤判。
|
||||
type triggerEnvelope struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// triggerInner=工作流最後一個節點的輸出裡,我們唯一認得的兩個欄位。
|
||||
type triggerInner struct {
|
||||
Success *bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// webhookFailure 檢查「HTTP 2xx 的觸發回應裡,工作流是不是其實失敗了」。
|
||||
//
|
||||
// 回空字串=沒看出失敗(真的成功,或這個回應我們看不懂——兩者都放行)。
|
||||
// 回非空=**確定失敗**,字串是給使用者看的那句話。
|
||||
func webhookFailure(body string) string {
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if trimmed == "" || (!strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[")) {
|
||||
return "" // 不是 JSON ⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
var env triggerEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return "" // 解析不了(含被截斷)⇒ 看不出來 ⇒ 放行
|
||||
}
|
||||
// ① 外層自己就說失敗
|
||||
if env.Success != nil && !*env.Success {
|
||||
return ingestFailureSentence(env.Error)
|
||||
}
|
||||
// ② 外層說成功,但工作流的輸出說失敗——本檔存在的理由就是這一格
|
||||
if len(env.Data) > 0 {
|
||||
var inner triggerInner
|
||||
if err := json.Unmarshal(env.Data, &inner); err == nil && inner.Success != nil && !*inner.Success {
|
||||
return ingestFailureSentence(inner.Error)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ingestFailureSentence 把上游那串技術文字換成一句使用者讀得懂的話。
|
||||
//
|
||||
// 🔴 不是 debug 訊息,是產品文案:使用者看到「已整理 26 份」卻查不到東西的當下,
|
||||
// 唯一能讓他知道發生什麼事的就是這句(#104 的紅線:不要讓他猜)。
|
||||
// 認不出來的原因不編故事,只誠實說「雲端沒有寫進去」。
|
||||
func ingestFailureSentence(raw string) string {
|
||||
const head = "雲端收下了,但你的知識庫沒有真的寫進去(這一份還查不到)"
|
||||
switch {
|
||||
case strings.Contains(raw, "unreachable"):
|
||||
return head + ":連不到知識庫的資料層。稍後會自動再試。"
|
||||
case strings.Contains(raw, "card_content 為空"):
|
||||
return head + ":這份檔萃出來是空的。"
|
||||
case strings.Contains(raw, "credential"):
|
||||
return head + ":知識庫的內部金鑰不對,要重裝一次雲端才會通。"
|
||||
default:
|
||||
return head + ",稍後會自動再試。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realFailingEnvelope 是 2026-08-26 從 youlin 實例抄回來的**原文**:
|
||||
// HTTP 200、外層 success=true,而工作流其實一個字都沒寫進 KBDB。
|
||||
// 這一份不是虛構的樣本,是這一票的物證——改動這裡等於改動證據。
|
||||
const realFailingEnvelope = `{"success":true,"data":{"success":false,"status":500,` +
|
||||
`"error":"{\"success\":false,\"error\":\"unreachable\"}"},"duration_ms":2476}`
|
||||
|
||||
func TestWebhookFailure_實測那份回應必須被判成失敗(t *testing.T) {
|
||||
msg := webhookFailure(realFailingEnvelope)
|
||||
if msg == "" {
|
||||
t.Fatal("外層 success=true、內層 success=false ⇒ 必須判失敗,否則就是 2026-08-26 那個假綠")
|
||||
}
|
||||
if !strings.Contains(msg, "沒有真的寫進去") {
|
||||
t.Errorf("訊息要講出「沒寫進去」這件事:%q", msg)
|
||||
}
|
||||
// 使用者看得到這句話 ⇒ 不准出現狀態碼與上游 JSON 原文(同 direct_quota_test.go 的禁字表)
|
||||
for _, bad := range []string{"500", "HTTP", "unreachable", "{"} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("訊息裸露了技術細節 %q:%q", bad, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookFailure_只在看得懂的時候才判失敗(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
fail bool
|
||||
}{
|
||||
{"一切正常", `{"success":true,"data":{"success":true,"ok":1},"duration_ms":9}`, false},
|
||||
{"外層就說失敗", `{"success":false,"error":"workflow not found"}`, true},
|
||||
{"內層說失敗", realFailingEnvelope, true},
|
||||
{"data 沒有 success 欄位=看不出來", `{"success":true,"data":{"written":3}}`, false},
|
||||
{"data 是陣列", `{"success":true,"data":[1,2,3]}`, false},
|
||||
{"data 是字串", `{"success":true,"data":"done"}`, false},
|
||||
{"data 是 null", `{"success":true,"data":null}`, false},
|
||||
{"根本不是 JSON", `OK`, false},
|
||||
{"空回應", ``, false},
|
||||
{"被截斷的 JSON", `{"success":true,"data":{"success":false,"err`, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := webhookFailure(c.body) != ""
|
||||
if got != c.fail {
|
||||
t.Errorf("%s:判失敗=%v,預期 %v(body=%s)", c.name, got, c.fail, c.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔴 這支是本票的核心迴歸閘:**「已送達」這個章,只能在東西真的寫進去時才准蓋。**
|
||||
// 沒有它,同一個假綠會以任何一種新的包裝再回來一次
|
||||
//(2026-08-26 實錄:26 份蓋章、雲端 4 份,而畫面全綠)。
|
||||
func TestWiring_雲端說沒寫進去就不准蓋已送達的章(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "報銷規則.md"), []byte("# 報銷規則\n\n內容"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var triggers int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
|
||||
return
|
||||
}
|
||||
_, _ = io.ReadAll(r.Body)
|
||||
triggers++
|
||||
// 就是實例當天回的那一份:200 + 外層綠、內層紅
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, realFailingEnvelope)
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer gemmaCardStub(t, cardFixture("報銷規則", "財務"))()
|
||||
|
||||
manifestPath := filepath.Join(t.TempDir(), "m.json")
|
||||
cfg := &DirectConfig{
|
||||
WatchFolders: []string{root},
|
||||
Manifest: manifestPath,
|
||||
CypherURL: srv.URL, Namespace: "demo", APIKey: "demo",
|
||||
Library: "kb", Extractor: "gemma", ExtractorExplicit: true, GeminiAPIKey: "k-test",
|
||||
CardIngestWF: "rag_ingest_card", MaxRemoved: DefaultMaxRemovedRatio,
|
||||
}
|
||||
results, exit, _ := RunDirectOnce(cfg, false)
|
||||
if triggers == 0 {
|
||||
t.Fatal("測試沒打到觸發端點,這支測試等於沒驗到東西")
|
||||
}
|
||||
if exit == 0 {
|
||||
t.Errorf("一份都沒真的寫進去,exit 不該是 0:%+v", results)
|
||||
}
|
||||
_, fileResults := splitInventory(results)
|
||||
if len(fileResults) != 1 || fileResults[0].Status != "failed" {
|
||||
t.Fatalf("那份檔應該是 failed,不是「已送達」:%+v", fileResults)
|
||||
}
|
||||
if !strings.Contains(fileResults[0].Error, "沒有真的寫進去") {
|
||||
t.Errorf("失敗理由要講人話:%q", fileResults[0].Error)
|
||||
}
|
||||
// 最關鍵的一格:manifest 不准留下「已送達」的章——留了,content_hash 沒變就永遠不會重送。
|
||||
m, err := LoadManifest(manifestPath, root)
|
||||
if err != nil {
|
||||
t.Fatalf("讀 manifest:%v", err)
|
||||
}
|
||||
for rel, e := range m.Entries {
|
||||
if e.IngestedHash != "" && e.IngestedHash == e.ContentHash {
|
||||
t.Errorf("%s 蓋了「已送達」章,但雲端根本沒收到 ⇒ 這份知識會永久消失", rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 太大的檔:明知送出去一定失敗,就不要送——而且理由要是人話。
|
||||
func TestTooBigForWorkersAI_講人話且不送出去(t *testing.T) {
|
||||
if why := tooBigForWorkersAI(strings.Repeat("a", 1000), "小檔.md"); why != "" {
|
||||
t.Errorf("一般大小的檔不該被擋:%q", why)
|
||||
}
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes) // 中文一字 3 bytes ⇒ 一定超過
|
||||
why := tooBigForWorkersAI(big, "mistakes.md")
|
||||
if why == "" {
|
||||
t.Fatal("超過上限的檔要被擋下")
|
||||
}
|
||||
if !strings.Contains(why, "萬字") || !strings.Contains(why, "拆成") {
|
||||
t.Errorf("要講出多大、以及使用者能做什麼:%q", why)
|
||||
}
|
||||
for _, bad := range []string{"token", "131000", "HTTP", "8007", "llama"} {
|
||||
if strings.Contains(why, bad) {
|
||||
t.Errorf("訊息裸露技術細節 %q:%q", bad, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWithWorkersAI_太大的檔一個請求都不送(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
big := strings.Repeat("字", maxWorkersAIExtractBytes)
|
||||
if err := os.WriteFile(filepath.Join(root, "巨檔.md"), []byte("# 巨檔\n\n"+big), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "output": "{}"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := ExtractWithWorkersAI(srv.URL, "k", root, "巨檔.md")
|
||||
if err == nil {
|
||||
t.Fatal("太大的檔應該直接失敗")
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Errorf("擋下的檔不該還打雲端一次(燒額度+佔佇列),實際打了 %d 次", hits)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "太大") {
|
||||
t.Errorf("理由要講人話:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user