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