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
}
// Sanitize removes ellipsis placeholders and normalizes stray commas that sit
// outside of string literals. It returns nil when nothing changed so callers
// can avoid a redundant re-parse. String/URL contents are never altered.
// Sanitize removes ellipsis placeholders, escapes unescaped control characters
// inside JSON string literals, and normalizes stray commas that sit outside of
// 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 {
normalized := normalizeCommas(stripEllipsis(string(in)))
normalized := normalizeCommas(stripEllipsis(escapeControlCharsInStrings(string(in))))
if normalized == string(in) {
return nil
}
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 {
var b strings.Builder
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) {
raw := []byte(`{"note":"see ... and https://a.b/c", "list":[1, ...]}`)
cleaned := Sanitize(raw)

View File

@ -1,3 +1,2 @@
只回傳以下 JSON 結構,不要 markdown
只回傳以下 JSON 結構,不要 markdown。所有字串值必須是單行 JSON string需要換行時用 `\n`,禁止在引號內直接換行。
{"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不同內容型態的鬆結構例如心情陪伴、知識整理、清單工具、互動文、幽默共鳴不要變成固定模板。
- knowledgeTranslation如何把外部資料、醫療知識、產品知識轉成此人設會講的話哪些資訊要先安撫、哪些要列點、哪些要加風險提醒。
- ctaStyle收藏、留言、分享、轉發給伴侶等 CTA 的自然用法;也要說明何時不該 CTA。
- examples抽象仿寫範例可展示語氣與換行但不可逐字複製原文。
- examples抽象仿寫範例可展示語氣與換行但不可逐字複製原文JSON 內若要換行請用 `\n`,不可在字串值內直接斷行
- avoid不像此人設的寫法、AI 味、過度模板、冒犯或高風險語句。

View File

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

View File

@ -5,6 +5,18 @@ import (
"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) {
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)

View File

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

View File

@ -145,6 +145,18 @@ raise SystemExit('no free Vite port found in 5173..5199')
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
cd "$BACKEND_DIR"
@ -198,7 +210,7 @@ start "node-worker:style-8d" env \
npm run style-8d
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
wait_for_http "web" "http://127.0.0.1:$WEB_PORT/"