- 排除 .env / *.bak / 内部运维文档(README_INTERNAL.html) - config.py 默认密码已替换为占位符 CHANGE_ME_* - init_db.sql 移除生产数据库用户 GRANT 段 - README.html 数据库用户名已脱敏 - 保留:源码 + 公网 API 文档 + 建表 SQL(无授权语句)
345 lines
13 KiB
Python
345 lines
13 KiB
Python
"""Shared LLM response parsing utilities for lifecycle modules."""
|
|
import json
|
|
import logging
|
|
import re
|
|
import time
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_PARSE_RETRIES = 2 # 3 total attempts (1 initial + 2 retry)
|
|
|
|
RETRY_SYSTEM_PROMPT = (
|
|
'只输出原始JSON。不要有任何其他文字、分析、思考、解释、markdown。\n'
|
|
'输出格式示例:\n'
|
|
'[{"name": "场景名", "summary": "摘要", "memory_ids": ["id1", "id2"]}]\n'
|
|
'重要:你回复的第一个字符必须是 [,最后一个字符必须是 ]。'
|
|
)
|
|
|
|
RETRY_OBJECT_PROMPT = (
|
|
'只输出原始JSON对象。不要有任何其他文字、分析、思考、解释、markdown。\n'
|
|
'输出格式:\n'
|
|
'{"data": [{"name": "场景名", "summary": "摘要", "memory_ids": ["id1", "id2"]}]}\n'
|
|
'重要:你回复的第一个字符必须是 {,最后一个字符必须是 }。'
|
|
)
|
|
|
|
|
|
def _strip_markdown_code_block(text: str) -> str:
|
|
text = text.strip()
|
|
if not text.startswith("```"):
|
|
return text
|
|
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
|
|
if text.endswith("```"):
|
|
text = text[:-3].strip()
|
|
if text.lower().startswith("json"):
|
|
text = text[4:].strip()
|
|
elif text.lower().startswith("json\n"):
|
|
text = text[5:].strip()
|
|
return text.strip()
|
|
|
|
|
|
def _strip_reasoning_prefix(text: str) -> str:
|
|
"""Strip reasoning/analysis text that some models output before JSON.
|
|
|
|
Models like deepseek-v4-flash often output chain-of-thought analysis
|
|
before the actual JSON. This function tries to find where the JSON
|
|
actually starts by looking for lines that start with [ or { after
|
|
stripping analysis text.
|
|
"""
|
|
text = text.strip()
|
|
|
|
# If text starts with [ or { it might already be JSON
|
|
if text and text[0] in ('[', '{'):
|
|
return text
|
|
|
|
# Try to find the last occurrence of a JSON-like pattern
|
|
lines = text.split('\n')
|
|
for i in range(len(lines) - 1, -1, -1):
|
|
stripped = lines[i].strip()
|
|
if stripped.startswith('[') or stripped.startswith('{'):
|
|
candidate = '\n'.join(lines[i:])
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
|
|
# Try finding first [ or { that starts a valid JSON
|
|
for start_char in ['[', '{']:
|
|
idx = text.find(start_char)
|
|
while idx >= 0:
|
|
candidate = text[idx:]
|
|
end_char = ']' if start_char == '[' else '}'
|
|
if candidate.rstrip().endswith(end_char):
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
idx = text.find(start_char, idx + 1)
|
|
|
|
return text
|
|
|
|
|
|
def _find_json_string(text: str) -> Optional[str]:
|
|
"""Find the first valid JSON array or object in text.
|
|
|
|
Iterates through ALL bracket-delimited segments, not just from the
|
|
first bracket. This handles cases where the LLM outputs analysis
|
|
text containing bracketed IDs before the actual JSON.
|
|
"""
|
|
text = text.strip()
|
|
if not text:
|
|
return None
|
|
|
|
# Fast path: entire text is valid JSON
|
|
if (text.startswith("[") or text.startswith("{")) and text[-1] in ("}", "]"):
|
|
try:
|
|
json.loads(text)
|
|
return text
|
|
except (json.JSONDecodeError, ValueError):
|
|
pass
|
|
|
|
# Strip markdown code blocks
|
|
cleaned = _strip_markdown_code_block(text)
|
|
if cleaned != text:
|
|
return _find_json_string(cleaned)
|
|
|
|
# Strip reasoning prefix
|
|
cleaned = _strip_reasoning_prefix(text)
|
|
if cleaned != text:
|
|
result = _find_json_string(cleaned)
|
|
if result:
|
|
return result
|
|
|
|
# Scan all bracket-delimited segments (for "[" / "]" and "{" / "}")
|
|
for start_char, end_char in [("[", "]"), ("{", "}")]:
|
|
search_start = 0
|
|
while True:
|
|
start_idx = text.find(start_char, search_start)
|
|
if start_idx == -1:
|
|
break
|
|
depth = 0
|
|
for i in range(start_idx, len(text)):
|
|
if text[i] == start_char:
|
|
depth += 1
|
|
elif text[i] == end_char:
|
|
depth -= 1
|
|
if depth == 0:
|
|
candidate = text[start_idx:i + 1]
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except (json.JSONDecodeError, ValueError):
|
|
# Move past this bracket pair and continue scanning
|
|
search_start = i + 1
|
|
break
|
|
else:
|
|
# No matching end bracket found
|
|
search_start = start_idx + 1
|
|
|
|
# Regex fallback: find ALL bracket-delimited segments
|
|
for pattern in [r"\[.*\]", r"\{.*\}"]:
|
|
for match in re.finditer(pattern, text, re.DOTALL):
|
|
candidate = match.group(0)
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
def _try_fix_json(text: str) -> Optional[str]:
|
|
"""Try to fix common JSON issues from LLM output."""
|
|
# Remove trailing commas before } or ]
|
|
text = re.sub(r",\s*}", "}", text)
|
|
text = re.sub(r",\s*\]", "]", text)
|
|
# Replace single quotes with double quotes only if no double quotes present
|
|
if '"' not in text:
|
|
text = text.replace("'", '"')
|
|
# Remove BOM and zero-width characters
|
|
text = text.replace("\ufeff", "").replace("\u200b", "")
|
|
# Try parsing as-is first
|
|
try:
|
|
json.loads(text)
|
|
return text
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Try fixing unescaped newlines inside JSON string values
|
|
fixed = re.sub(r'(?<=: ")((?:[^"]|\\")*?)(\n)((?:[^"]|\\")*?(?="))', r'\1\\n\3', text)
|
|
try:
|
|
json.loads(fixed)
|
|
return fixed
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Try wrapping in array if it looks like a bare object
|
|
stripped = text.strip()
|
|
if stripped.startswith("{") and not stripped.startswith("["):
|
|
wrapped = "[" + stripped + "]"
|
|
try:
|
|
json.loads(wrapped)
|
|
return wrapped
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Try to extract JSON from truncated output: find last complete object in array
|
|
if stripped.startswith("["):
|
|
depth = 0
|
|
objects = []
|
|
obj_start = -1
|
|
for i, ch in enumerate(stripped):
|
|
if ch == "{":
|
|
if depth == 0:
|
|
obj_start = i
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0 and obj_start >= 0:
|
|
objects.append(stripped[obj_start:i + 1])
|
|
obj_start = -1
|
|
if objects:
|
|
candidate = "[" + ",".join(objects) + "]"
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return None
|
|
|
|
|
|
def parse_llm_json_array(llm_client, system_prompt, user_prompt,
|
|
temperature=0.3, context="", max_tokens=4000):
|
|
last_raw = ""
|
|
total = 1 + MAX_PARSE_RETRIES
|
|
t_start = time.time()
|
|
for attempt in range(total):
|
|
is_retry = attempt > 0
|
|
|
|
if is_retry:
|
|
if attempt >= total - 1:
|
|
# Final attempt: json_mode as last resort with wrapper object
|
|
prompt = RETRY_OBJECT_PROMPT
|
|
use_json_mode = True
|
|
prefill_val = None
|
|
else:
|
|
# Intermediate retry: use prefill "["
|
|
prompt = RETRY_SYSTEM_PROMPT
|
|
use_json_mode = False
|
|
prefill_val = "["
|
|
else:
|
|
# First attempt: prefill "[" to force JSON output immediately
|
|
prompt = system_prompt
|
|
use_json_mode = False
|
|
prefill_val = "["
|
|
|
|
response = llm_client.chat(
|
|
prompt, user_prompt,
|
|
temperature=temperature, json_mode=use_json_mode,
|
|
max_tokens=max_tokens,
|
|
thinking={"type": "disabled"},
|
|
prefill=prefill_val,
|
|
)
|
|
if not response:
|
|
logger.warning("[%s] Attempt %d: LLM returned empty", context, attempt + 1)
|
|
continue
|
|
last_raw = response
|
|
json_str = _find_json_string(response)
|
|
if json_str:
|
|
try:
|
|
result = json.loads(json_str)
|
|
except json.JSONDecodeError as e:
|
|
logger.warning("[%s] Attempt %d: JSON parse error: %s", context, attempt + 1, e)
|
|
continue
|
|
if isinstance(result, list):
|
|
elapsed = time.time() - t_start
|
|
logger.info("[%s] Parsed JSON array in %.1fs (%d attempt(s), %d items)", context, elapsed, attempt + 1, len(result))
|
|
return result
|
|
if isinstance(result, dict):
|
|
if "data" in result and isinstance(result["data"], list):
|
|
elapsed = time.time() - t_start
|
|
logger.info("[%s] Parsed JSON array in %.1fs (%d attempt(s), %d items)", context, elapsed, attempt + 1, len(result["data"]))
|
|
return result["data"]
|
|
logger.warning("[%s] Attempt %d: LLM returned dict, wrapping in list", context, attempt + 1)
|
|
return [result]
|
|
fixed = _try_fix_json(response)
|
|
if fixed:
|
|
try:
|
|
result = json.loads(fixed)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(result, list):
|
|
return result
|
|
if isinstance(result, dict):
|
|
if "data" in result and isinstance(result["data"], list):
|
|
return result["data"]
|
|
return [result]
|
|
logger.warning(
|
|
"[%s] Attempt %d/%d: No valid JSON array found (len=%d): %r",
|
|
context, attempt + 1, total, len(response), response[:500]
|
|
)
|
|
logger.error("[%s] All %d attempts failed. Last raw: %r", context, total, last_raw[:500])
|
|
raise ValueError("LLM failed to return valid JSON after retries")
|
|
|
|
|
|
def parse_llm_json_object(llm_client, system_prompt, user_prompt,
|
|
temperature=0.3, context="", max_tokens=4000):
|
|
last_raw = ""
|
|
total = 1 + MAX_PARSE_RETRIES
|
|
t_start = time.time()
|
|
for attempt in range(total):
|
|
is_retry = attempt > 0
|
|
|
|
if is_retry:
|
|
if attempt >= total - 1:
|
|
# Final attempt: json_mode as last resort
|
|
prompt = '只输出 JSON 对象。不要输出任何其他文字。\n输出格式:{"preferences":[],"habits":[],"expertise":[],"communication_style":"","summary":""}'
|
|
use_json_mode = True
|
|
prefill_val = None
|
|
else:
|
|
prompt = '只输出 JSON 对象。不要输出任何其他文字。\n禁止输出:分析、思考、解释、讨论、描述、markdown。'
|
|
use_json_mode = False
|
|
prefill_val = "{"
|
|
else:
|
|
# First attempt: prefill "{" to force JSON output
|
|
prompt = system_prompt
|
|
use_json_mode = False
|
|
prefill_val = "{"
|
|
|
|
response = llm_client.chat(
|
|
prompt, user_prompt,
|
|
temperature=temperature, json_mode=use_json_mode,
|
|
max_tokens=max_tokens,
|
|
thinking={"type": "disabled"},
|
|
prefill=prefill_val,
|
|
)
|
|
if not response:
|
|
logger.warning("[%s] Attempt %d: LLM returned empty", context, attempt + 1)
|
|
continue
|
|
last_raw = response
|
|
json_str = _find_json_string(response)
|
|
if json_str:
|
|
try:
|
|
result = json.loads(json_str)
|
|
except json.JSONDecodeError as e:
|
|
logger.warning("[%s] Attempt %d: JSON parse error: %s", context, attempt + 1, e)
|
|
continue
|
|
if isinstance(result, dict):
|
|
elapsed = time.time() - t_start
|
|
logger.info("[%s] Parsed JSON object in %.1fs (%d attempt(s))", context, elapsed, attempt + 1)
|
|
return result
|
|
fixed = _try_fix_json(response)
|
|
if fixed:
|
|
try:
|
|
result = json.loads(fixed)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(result, dict):
|
|
return result
|
|
logger.warning(
|
|
"[%s] Attempt %d/%d: No valid JSON object found (len=%d): %r",
|
|
context, attempt + 1, total, len(response), response[:500]
|
|
)
|
|
logger.error("[%s] All %d attempts failed. Last raw: %r", context, total, last_raw[:500])
|
|
raise ValueError("LLM failed to return valid JSON after retries")
|