This commit is contained in:
王性驊 2026-07-07 12:12:04 +00:00
parent 152d42e77b
commit 5acd1b6567
8 changed files with 124 additions and 19 deletions

View File

@ -24,17 +24,71 @@ func Unmarshal(payload []byte, v any) error {
return err return err
} }
// Sanitize removes ellipsis placeholders and normalizes stray commas that sit // Sanitize removes ellipsis placeholders, escapes unescaped control characters
// outside of string literals. It returns nil when nothing changed so callers // inside JSON string literals, and normalizes stray commas that sit outside of
// can avoid a redundant re-parse. String/URL contents are never altered. // string literals. It returns nil when nothing changed so callers can avoid a
// redundant re-parse. String meaning is preserved; only invalid JSON escapes
// are added.
func Sanitize(in []byte) []byte { func Sanitize(in []byte) []byte {
normalized := normalizeCommas(stripEllipsis(string(in))) normalized := normalizeCommas(stripEllipsis(escapeControlCharsInStrings(string(in))))
if normalized == string(in) { if normalized == string(in) {
return nil return nil
} }
return []byte(normalized) return []byte(normalized)
} }
func escapeControlCharsInStrings(s string) string {
var b strings.Builder
b.Grow(len(s) + 16)
inString := false
escaped := false
for i := 0; i < len(s); i++ {
ch := s[i]
if inString {
if escaped {
escaped = false
b.WriteByte(ch)
continue
}
switch ch {
case '\\':
escaped = true
b.WriteByte(ch)
case '"':
inString = false
b.WriteByte(ch)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if ch < 0x20 {
b.WriteString(`\u00`)
b.WriteByte(hexDigit(ch >> 4))
b.WriteByte(hexDigit(ch & 0x0f))
} else {
b.WriteByte(ch)
}
}
continue
}
if ch == '"' {
inString = true
}
b.WriteByte(ch)
}
return b.String()
}
func hexDigit(v byte) byte {
if v < 10 {
return '0' + v
}
return 'a' + (v - 10)
}
func stripEllipsis(s string) string { func stripEllipsis(s string) string {
var b strings.Builder var b strings.Builder
b.Grow(len(s)) b.Grow(len(s))

View File

@ -35,6 +35,28 @@ func TestSanitizeReturnsNilWhenValid(t *testing.T) {
} }
} }
func TestSanitizeEscapesNewlinesInsideStrings(t *testing.T) {
raw := []byte(`{"examples":"第一行
第二行","identity":"觀察者"}`)
cleaned := Sanitize(raw)
if cleaned == nil {
t.Fatal("expected sanitized output")
}
var out struct {
Examples string `json:"examples"`
Identity string `json:"identity"`
}
if err := Unmarshal(raw, &out); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if out.Examples != "第一行\n第二行" {
t.Fatalf("examples = %q", out.Examples)
}
if out.Identity != "觀察者" {
t.Fatalf("identity = %q", out.Identity)
}
}
func TestSanitizeKeepsDotsInsideStrings(t *testing.T) { func TestSanitizeKeepsDotsInsideStrings(t *testing.T) {
raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`) raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`)
cleaned := Sanitize(raw) cleaned := Sanitize(raw)

View File

@ -1,3 +1,2 @@
只回傳以下 JSON 結構,不要 markdown。所有字串值必須是單行 JSON string需要換行時用 `\n`,禁止在引號內直接換行。
只回傳以下 JSON 結構,不要 markdown
{"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}} {"d1Tone":{"summary":"","evidence":[]},"d2Structure":{"summary":"","evidence":[]},"d3Interaction":{"summary":"","evidence":[]},"d4Topics":{"summary":"","evidence":[]},"d5Rhythm":{"summary":"","evidence":[]},"d6Visual":{"summary":"","evidence":[]},"d7Conversion":{"summary":"","evidence":[]},"d8Risk":{"summary":"","evidence":[]},"personaDraft":{"identity":"","tone":"","audience":"","hooks":"","languageFingerprint":"","rhythm":"","punctuation":"","contentPatterns":"","knowledgeTranslation":"","ctaStyle":"","examples":"","avoid":""}}

View File

@ -17,5 +17,5 @@ personaDraft 必須產出可執行的寫作規則,不是抽象形容詞:
- contentPatterns不同內容型態的鬆結構例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴不要變成固定模板。 - contentPatterns不同內容型態的鬆結構例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴不要變成固定模板。
- knowledgeTranslation如何把外部資料、醫療知識、產品知識轉成此人設會講的話哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。 - knowledgeTranslation如何把外部資料、醫療知識、產品知識轉成此人設會講的話哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。
- ctaStyle收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。 - ctaStyle收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。
- examples抽象仿寫範例可展示語氣與換行但不可逐字複製原文。 - examples抽象仿寫範例可展示語氣與換行但不可逐字複製原文JSON 內若要換行請用 `\n`,不可在字串值內直接斷行
- avoid不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。 - avoid不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。

View File

@ -8,6 +8,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"haixun-backend/internal/library/llmjson"
) )
type Post struct { type Post struct {
@ -290,13 +292,13 @@ func ParseLLMOutput(raw string) (LLMOutput, error) {
return LLMOutput{}, err return LLMOutput{}, err
} }
var root map[string]json.RawMessage var root map[string]json.RawMessage
if err := json.Unmarshal(payload, &root); err != nil { if err := llmjson.Unmarshal(payload, &root); err != nil {
return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err) return LLMOutput{}, fmt.Errorf("parse style8d json: %w", err)
} }
for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} { for _, key := range []string{"analysis", "style8d", "style8D", "result", "data", "output"} {
if nested, ok := root[key]; ok { if nested, ok := root[key]; ok {
var merged map[string]json.RawMessage var merged map[string]json.RawMessage
if err := json.Unmarshal(nested, &merged); err == nil { if err := llmjson.Unmarshal(nested, &merged); err == nil {
for k, v := range merged { for k, v := range merged {
if _, exists := root[k]; !exists { if _, exists := root[k]; !exists {
root[k] = v root[k] = v
@ -473,11 +475,11 @@ func parseDimension(root map[string]json.RawMessage, keys ...string) Dimension {
func decodeDimension(raw json.RawMessage) Dimension { func decodeDimension(raw json.RawMessage) Dimension {
var asString string var asString string
if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
return Dimension{Summary: strings.TrimSpace(asString)} return Dimension{Summary: strings.TrimSpace(asString)}
} }
var obj map[string]json.RawMessage var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil { if err := llmjson.Unmarshal(raw, &obj); err != nil {
return Dimension{} return Dimension{}
} }
summary := firstString(obj, "summary", "description", "analysis", "conclusion") summary := firstString(obj, "summary", "description", "analysis", "conclusion")
@ -492,11 +494,11 @@ func parsePersonaDraft(root map[string]json.RawMessage) PersonaDraft {
continue continue
} }
var asString string var asString string
if err := json.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" { if err := llmjson.Unmarshal(raw, &asString); err == nil && strings.TrimSpace(asString) != "" {
return PersonaDraft{Identity: strings.TrimSpace(asString)} return PersonaDraft{Identity: strings.TrimSpace(asString)}
} }
var obj map[string]json.RawMessage var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil { if err := llmjson.Unmarshal(raw, &obj); err != nil {
continue continue
} }
return PersonaDraft{ return PersonaDraft{
@ -524,7 +526,7 @@ func firstString(obj map[string]json.RawMessage, keys ...string) string {
continue continue
} }
var value string var value string
if err := json.Unmarshal(raw, &value); err == nil { if err := llmjson.Unmarshal(raw, &value); err == nil {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if value != "" { if value != "" {
return value return value
@ -541,7 +543,7 @@ func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string {
continue continue
} }
var values []string var values []string
if err := json.Unmarshal(raw, &values); err == nil { if err := llmjson.Unmarshal(raw, &values); err == nil {
out := make([]string, 0, len(values)) out := make([]string, 0, len(values))
for _, item := range values { for _, item := range values {
item = strings.TrimSpace(item) item = strings.TrimSpace(item)
@ -554,7 +556,7 @@ func firstStringSlice(obj map[string]json.RawMessage, keys ...string) []string {
} }
} }
var single string var single string
if err := json.Unmarshal(raw, &single); err == nil { if err := llmjson.Unmarshal(raw, &single); err == nil {
single = strings.TrimSpace(single) single = strings.TrimSpace(single)
if single != "" { if single != "" {
return []string{single} return []string{single}

View File

@ -5,6 +5,18 @@ import (
"testing" "testing"
) )
func TestParseLLMOutputWithLiteralNewlinesInStrings(t *testing.T) {
raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落","evidence":[]},"d3Interaction":{"summary":"提問","evidence":[]},"d4Topics":{"summary":"生活","evidence":[]},"d5Rhythm":{"summary":"晚間","evidence":[]},"d6Visual":{"summary":"emoji","evidence":[]},"d7Conversion":{"summary":"留言","evidence":[]},"d8Risk":{"summary":"硬銷","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"上班族","hooks":"痛點","languageFingerprint":"有時候","rhythm":"短段落","punctuation":"逗號","contentPatterns":"心情文","knowledgeTranslation":"先講情境","ctaStyle":"留言","examples":"有時候真的會累
後來就習慣了","avoid":"硬銷"}}`
out, err := ParseLLMOutput(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(out.PersonaDraft.Examples, "後來就習慣了") {
t.Fatalf("examples=%q", out.PersonaDraft.Examples)
}
}
func TestParseLLMOutput(t *testing.T) { func TestParseLLMOutput(t *testing.T) {
raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}` raw := `{"d1Tone":{"summary":"口語親近","evidence":["真的太好笑"]},"d2Structure":{"summary":"短段落開場","evidence":[]},"d3Interaction":{"summary":"常用提問","evidence":[]},"d4Topics":{"summary":"生活與成長","evidence":[]},"d5Rhythm":{"summary":"晚間發文較多","evidence":[]},"d6Visual":{"summary":"愛用換行與 emoji","evidence":[]},"d7Conversion":{"summary":"自然導留言","evidence":[]},"d8Risk":{"summary":"避免照抄","evidence":[]},"personaDraft":{"identity":"生活觀察者","tone":"輕鬆","audience":"同齡上班族","hooks":"先丟痛點","languageFingerprint":"常用有時候、後來當轉折","rhythm":"短段落加空行","punctuation":"保留逗號與問號","contentPatterns":"心情文先承接情緒","knowledgeTranslation":"先講情境再整理重點","ctaStyle":"自然邀請留言","examples":"有時候真的會累","avoid":"不要硬銷"}}`
out, err := ParseLLMOutput(raw) out, err := ParseLLMOutput(raw)

View File

@ -6,8 +6,12 @@ export default defineConfig({
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 5173, port: 5173,
// 支援透過 https://threads-tool.30cm.net 存取開發伺服器(需反向代理轉發) // 支援透過 nginx 網域存取開發伺服器(需反向代理轉發)
allowedHosts: ["threads-tool.30cm.net", "localhost"], allowedHosts: [
"threads-tool.30cm.net",
"threads-tool-dev.30cm.net",
"localhost",
],
// 如果反向代理在 https 並轉發 HMR websocket請視代理設定調整 hmr // 如果反向代理在 https 並轉發 HMR websocket請視代理設定調整 hmr
// hmr: { // hmr: {
// host: "threads-tool.30cm.net", // host: "threads-tool.30cm.net",

View File

@ -145,6 +145,18 @@ raise SystemExit('no free Vite port found in 5173..5199')
PY PY
} }
resolve_web_port() {
local requested="${WEB_PORT:-}"
if [[ -n "$requested" && "$requested" -ge 1024 ]]; then
echo "$requested"
return
fi
if [[ -n "$requested" ]]; then
echo "[dev-all] WEB_PORT=$requested is for prod/nginx; using a free dev port instead" >&2
fi
pick_web_port
}
load_env load_env
cd "$BACKEND_DIR" cd "$BACKEND_DIR"
@ -198,7 +210,7 @@ start "node-worker:style-8d" env \
npm run style-8d npm run style-8d
cd "$WEB_DIR" cd "$WEB_DIR"
WEB_PORT="${WEB_PORT:-$(pick_web_port)}" WEB_PORT="$(resolve_web_port)"
start "web" npm run dev -- --host 0.0.0.0 --port "$WEB_PORT" --strictPort start "web" npm run dev -- --host 0.0.0.0 --port "$WEB_PORT" --strictPort
wait_for_http "web" "http://127.0.0.1:$WEB_PORT/" wait_for_http "web" "http://127.0.0.1:$WEB_PORT/"