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