,保持层级)】
{html_tpl}
{build_report_json_markers(cats_literal=cats_literal)}
"""
return system, user
def _marker_body(raw: str, marker: str) -> str:
if marker not in raw:
return ""
part = raw.split(marker, 1)[1]
for end_marker in REPORT_MARKERS:
if end_marker != marker and end_marker in part:
part = part.split(end_marker, 1)[0]
part = part.strip()
part = re.sub(r"^```(?:json)?\s*", "", part, flags=re.I)
part = re.sub(r"\s*```\s*$", "", part)
return part
def _json_block_slice(raw: str, marker: str) -> Tuple[str, str]:
"""返回 (marker 后正文, 用于 json.loads 的子串)。"""
part = _marker_body(raw, marker)
if not part:
return part, ""
start, end = part.find("{"), part.rfind("}")
if start != -1 and end > start:
return part, part[start : end + 1]
if start != -1:
return part, part[start:].strip()
return part, ""
def _coerce_word_category_data(data: Dict[str, Any]) -> Dict[str, Any]:
"""将 LLM 各类别块统一为 {words: [...], analysis: str}。"""
out: Dict[str, Any] = {}
pending_analysis: str | None = None
for key, val in data.items():
if key == "analysis":
if isinstance(val, str):
pending_analysis = val
continue
if not isinstance(val, (dict, list)):
continue
if isinstance(val, list):
out[key] = {"words": val, "analysis": ""}
else:
words = val.get("words")
if words is None:
words = val.get("word")
out[key] = {
"words": list(words) if isinstance(words, list) else [],
"analysis": str(val.get("analysis") or ""),
}
if pending_analysis and len(out) == 1:
only_key = next(iter(out))
if not out[only_key].get("analysis"):
out[only_key]["analysis"] = pending_analysis
return out
def _trim_incomplete_json_tail(text: str) -> str:
"""去掉截断在引号/逗号上的尾部,便于补全括号。"""
s = text.rstrip()
while s.endswith(","):
s = s[:-1].rstrip()
m = re.search(r'(,\s*|\[\s*)\"[^\"\\]*$', s)
if m:
s = s[: m.start()].rstrip()
if s.endswith(","):
s = s[:-1].rstrip()
return s
def _close_truncated_json_object(text: str) -> str:
"""为截断在数组/对象中间的 JSON 片段补全括号(尽力而为)。"""
s = _close_truncated_json_braces(text)
if s and '"analysis"' not in s:
s = s.rstrip(", ") + ',"analysis":""'
if s.count("{") > s.count("}"):
s = s + "}"
return s
def _repair_word_category_json(js: str) -> Dict[str, Any] | None:
"""
修复 LLM 将各类别写成多段伪对象的情况,例如:
{"成分/原料":[...],"analysis":"..."}, "受众/使用对象":[...], "analysis":"..."}, ...
亦支持输出被截断、缺少最外层闭合括号的情形。
"""
text = js.strip()
if not text:
return None
if not text.startswith("{"):
text = "{" + text
parts = re.split(r"\}\s*,\s*(?=\")", text)
merged: Dict[str, Any] = {}
for i, part in enumerate(parts):
chunk = part.strip().rstrip(",").strip()
if not chunk:
continue
if i == 0:
if not chunk.endswith("}"):
chunk = _close_truncated_json_object(chunk)
else:
if not chunk.startswith("{"):
chunk = "{" + chunk
if not chunk.endswith("}"):
chunk = _close_truncated_json_object(chunk)
try:
obj = json.loads(chunk)
except json.JSONDecodeError:
continue
if not isinstance(obj, dict):
continue
merged.update(_coerce_word_category_data(obj))
return merged or None
def _extract_word_category_blocks_regex(text: str) -> Dict[str, Any] | None:
"""从截断/脏文本中抽取完整的「类别 + words + analysis」块。"""
if not text or "{" not in text:
return None
merged: Dict[str, Any] = {}
cat_alt = "|".join(re.escape(c) for c in WORD_CATEGORIES)
pattern = (
rf'\{{\s*"({cat_alt})"\s*:\s*(\[[^\]]*\])\s*,\s*'
r'"analysis"\s*:\s*"((?:[^"\\]|\\.)*)"\s*\}'
)
for m in re.finditer(pattern, text):
cat, words_json, analysis = m.group(1), m.group(2), m.group(3)
try:
words = json.loads(words_json)
except json.JSONDecodeError:
continue
if not isinstance(words, list):
continue
merged[cat] = {
"words": [str(w) for w in words if str(w).strip()],
"analysis": analysis.replace('\\"', '"'),
}
return merged or None
def _try_parse_word_category_json_block(raw: str) -> Tuple[Dict[str, Any], str | None]:
marker = "===WORD_CATEGORY_JSON==="
if marker not in raw:
return {}, f"缺少标记 {marker}"
part = _marker_body(raw, marker)
_part, js = _json_block_slice(raw, marker)
payload = js or (part[part.find("{") :] if "{" in part else part)
data: Dict[str, Any] | None = None
if payload:
try:
parsed = json.loads(payload)
if isinstance(parsed, dict):
data = _coerce_word_category_data(parsed)
except json.JSONDecodeError:
data = _repair_word_category_json(payload)
if data:
logger.info("已自动修复 %s 的非标准 JSON 结构", marker)
if not data and payload:
closed = _close_truncated_json_object(payload)
if closed != payload:
data = _repair_word_category_json(closed)
if data:
logger.info("已补全截断的 %s 并解析出 %s 类", marker, len(data))
if not data and part:
data = _extract_word_category_blocks_regex(part)
if data:
logger.info("已从 %s 截断文本中按类别块正则抽取 %s 类", marker, len(data))
if not data:
preview = (part or _part)[:200].replace("\n", " ")
return (
{},
f"{marker} 段内未找到合法 JSON 对象(无完整 {{...}});开头片段: {preview!r}",
)
return data, None
def _close_truncated_json_braces(text: str) -> str:
"""仅补全 [] / {{}},不注入 analysis 等字段。"""
s = _trim_incomplete_json_tail(text.strip())
if not s:
return s
if not s.startswith("{"):
s = "{" + s
if s.count("[") > s.count("]"):
s = s + "]"
if s.count("{") > s.count("}"):
s = s + "}"
return s
def _extract_json_kv_regex(text: str) -> Dict[str, str]:
"""从截断的 JSON 对象文本中提取已完整的 key:value 字符串对。"""
out: Dict[str, str] = {}
for m in re.finditer(
r'"((?:[^"\\]|\\.)+)"\s*:\s*"((?:[^"\\]|\\.)*)"',
text,
):
k = m.group(1).replace('\\"', '"')
v = m.group(2).replace('\\"', '"')
if k.strip() and v.strip():
out[k] = v
return out
def _loads_json_object_loose(payload: str) -> Tuple[Dict[str, Any] | None, str]:
"""
解析单个 JSON 对象。兼容尾部多余 `}}`、Extra data、以及真实截断时的补全/正则抽取。
返回 (dict, repair_note);repair_note 非空时仅用于日志。
"""
text = payload.strip()
if not text or "{" not in text:
return None, ""
def _loads_once(s: str) -> Dict[str, Any] | None:
try:
obj = json.loads(s)
except json.JSONDecodeError as e:
if e.msg == "Extra data" or "Extra data" in e.msg:
try:
obj, _idx = json.JSONDecoder().raw_decode(s)
except json.JSONDecodeError:
return None
else:
return None
return obj if isinstance(obj, dict) else None
obj = _loads_once(text)
if obj is not None:
tail = text[text.rfind("}") + 1 :].strip()
if tail:
return obj, "ignore_trailing_garbage"
return obj, ""
trimmed = text
while trimmed.endswith("}") and trimmed.count("{") < trimmed.count("}"):
trimmed = trimmed[:-1].rstrip()
obj = _loads_once(trimmed)
if obj is not None:
return obj, "trim_extra_brace"
closed = _close_truncated_json_braces(text)
if closed != text:
obj = _loads_once(closed)
if obj is not None:
return obj, "close_truncated"
kv = _extract_json_kv_regex(text)
if kv:
return kv, "regex_kv"
return None, ""
def _try_parse_json_block(raw: str, marker: str) -> Tuple[Dict[str, Any], str | None]:
if marker not in raw:
return {}, f"缺少标记 {marker}"
part = _marker_body(raw, marker)
_part, js = _json_block_slice(raw, marker)
payload = js or (part[part.find("{") :] if "{" in part else "")
if not payload:
preview = (part or _part)[:200].replace("\n", " ")
return {}, f"{marker} 段内未找到合法 JSON 对象(无完整 {{...}});开头片段: {preview!r}"
data, repair = _loads_json_object_loose(payload)
if data is not None and repair:
if repair == "regex_kv":
logger.info("已从损坏的 %s 中 regex 抽取 %s 个键值对", marker, len(data))
elif repair == "close_truncated":
logger.info("已补全截断的 %s(%s 个键)", marker, len(data))
elif repair in ("ignore_trailing_garbage", "trim_extra_brace"):
logger.debug("已宽松解析 %s(%s 个键,%s)", marker, len(data), repair)
if data is None:
try:
json.loads(payload)
except json.JSONDecodeError as e:
pos = e.pos if e.pos is not None else 0
ctx = payload[max(0, pos - 50) : pos + 50]
return (
{},
f"{marker} JSON 解析失败: {e.msg}(行{e.lineno}列{e.colno});"
f"错误附近: ...{ctx!r}...",
)
return {}, f"{marker} 必须是 JSON 对象"
if not isinstance(data, dict):
return {}, f"{marker} 必须是 JSON 对象,实际为 {type(data).__name__}"
return data, None
def _extract_json_block(raw: str, marker: str) -> Dict[str, Any]:
data, err = _try_parse_json_block(raw, marker)
if err:
logger.warning("解析 %s 失败: %s", marker, err)
return data
def _extract_report_html_fragment(raw: str) -> str:
if "===REPORT_HTML===" not in raw:
return ""
part = raw.split("===REPORT_HTML===", 1)[1]
for marker in REPORT_MARKERS[1:]:
if marker in part:
part = part.split(marker, 1)[0]
report_html = part.strip()
report_html = re.sub(r"```html?", "", report_html, flags=re.I)
report_html = report_html.replace("```", "").strip()
return _normalize_report_html_fragment(report_html)
def _validate_word_zh_json(data: Dict[str, Any], marker: str) -> List[str]:
errs: List[str] = []
if not data:
errs.append(f"{marker} 为空对象")
return errs
for k, v in data.items():
if not str(k).strip():
errs.append(f"{marker} 含空键名")
if not isinstance(v, str) or not str(v).strip():
errs.append(f"{marker} 键 {k!r} 的值必须为非空字符串")
break
return errs
def _validate_word_category_json(data: Dict[str, Any], marker: str) -> List[str]:
errs: List[str] = []
if not data:
errs.append(f"{marker} 为空对象")
return errs
has_any_words = False
for cat, block in data.items():
if not isinstance(block, dict):
errs.append(f"{marker} 类别 {cat!r} 的值必须是对象")
continue
words = block.get("words")
if words is not None:
if not isinstance(words, list):
errs.append(f"{marker} 类别 {cat!r}.words 必须是数组")
elif words:
has_any_words = True
analysis = block.get("analysis")
if analysis is not None and not isinstance(analysis, str):
errs.append(f"{marker} 类别 {cat!r}.analysis 必须是字符串")
if not has_any_words:
errs.append(f"{marker} 所有类别的 words 均为空")
return errs
def _validate_cluster_names_json(
data: Dict[str, Any], marker: str, *, expect_keys: bool
) -> List[str]:
errs: List[str] = []
if expect_keys and not data:
errs.append(f"{marker} 为空对象(需要为聚类簇命名)")
return errs
for k, v in data.items():
if not str(k).strip():
errs.append(f"{marker} 含空键名")
if not isinstance(v, str) or not str(v).strip():
errs.append(f"{marker} 键 {k!r} 的簇名必须为非空字符串")
break
return errs
def _dedupe_errors(errors: List[str]) -> List[str]:
return list(dict.fromkeys(errors))
def _validate_report_response(
raw: str, *, expect_cluster_names: bool
) -> List[str]:
errors: List[str] = []
for marker in REPORT_MARKERS:
if marker not in raw:
if marker == "===CLUSTER_NAMES_JSON===" and expect_cluster_names:
logger.warning(
"缺少 %s(将使用程序默认簇名);若频繁出现请检查模型 max_tokens 是否截断输出",
marker,
)
else:
errors.append(f"缺少标记 {marker}")
report_html = _extract_report_html_fragment(raw)
if not report_html:
errors.append("===REPORT_HTML=== 内容为空或无法提取 片段")
elif "
标题")
elif "
章节")
wzh, err = _try_parse_json_block(raw, "===WORD_ZH_JSON===")
if err:
errors.append(err)
else:
errors.extend(_validate_word_zh_json(wzh, "===WORD_ZH_JSON==="))
cats, err = _try_parse_word_category_json_block(raw)
if err:
errors.append(err)
else:
errors.extend(_validate_word_category_json(cats, "===WORD_CATEGORY_JSON==="))
if "===CLUSTER_NAMES_JSON===" in raw:
names, err = _try_parse_json_block(raw, "===CLUSTER_NAMES_JSON===")
if err:
errors.append(err)
else:
errors.extend(
_validate_cluster_names_json(
names,
"===CLUSTER_NAMES_JSON===",
expect_keys=expect_cluster_names and not names,
)
)
return _dedupe_errors(errors)
def _build_report_correction_user_message(errors: List[str], raw: str) -> str:
err_block = "\n".join(f"- {e}" for e in errors)
max_chars = 100_000
raw_body = raw if len(raw) <= max_chars else raw[:max_chars] + "\n\n...(上文已截断)..."
cats_literal = "、".join(WORD_CATEGORIES)
return build_report_correction_message(
err_block=err_block,
raw_body=raw_body,
cats_literal=cats_literal,
)
def _fetch_report_llm_raw_with_retry(
*,
system: str,
user: str,
api_key: str,
expect_cluster_names: bool,
max_retries: int = REPORT_PARSE_MAX_RETRIES,
) -> str:
messages: List[Dict[str, str]] = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
raw = _call_report_llm_messages(
messages,
api_key,
)
for attempt in range(max_retries + 1):
errors = _validate_report_response(raw, expect_cluster_names=expect_cluster_names)
if not errors:
if attempt > 0:
logger.info("报告 LLM 输出校验通过(第 %s 次修正后)", attempt + 1)
return raw
if attempt >= max_retries:
logger.warning(
"报告解析校验仍失败(已重试 %s 次),继续使用最后一次输出: %s",
max_retries,
errors,
)
return raw
logger.warning(
"报告解析校验失败(第 %s/%s 次),请求 LLM 修正: %s",
attempt + 1,
max_retries,
errors,
)
messages.append({"role": "assistant", "content": raw})
messages.append(
{
"role": "user",
"content": _build_report_correction_user_message(errors, raw),
}
)
raw = _call_report_llm_messages(
messages,
api_key,
)
return raw
def _parse_report_response(
raw: str,
) -> Tuple[str, Dict[str, str], Dict[str, Any], Dict[str, str]]:
report_html = _extract_report_html_fragment(raw)
word_zh_raw, _ = _try_parse_json_block(raw, "===WORD_ZH_JSON===")
word_zh = {str(k).lower(): str(v) for k, v in word_zh_raw.items()}
categories, _ = _try_parse_word_category_json_block(raw)
cluster_names_raw, _ = _try_parse_json_block(raw, "===CLUSTER_NAMES_JSON===")
cluster_names = {str(k): str(v) for k, v in cluster_names_raw.items()}
return report_html, word_zh, categories, cluster_names
def _zh_for_word(word: str, word_zh: Dict[str, str]) -> str:
return word_zh.get(word.lower(), word_zh.get(word, word))
def _normalize_word_category_data(category_data: Dict[str, Any]) -> Dict[str, Any]:
"""兼容旧版词频分类名「痛点/场景」→「需求/场景」。"""
if not category_data:
return category_data
out = dict(category_data)
legacy = out.pop("痛点/场景", None)
if legacy is not None and "需求/场景" not in out:
out["需求/场景"] = legacy
out.pop("其他", None)
return out
def _ensure_word_category_skeleton(category_data: Dict[str, Any]) -> Dict[str, Any]:
"""保证七个类别键存在且值为 {{words, analysis}} 结构。"""
base = _normalize_word_category_data(category_data or {})
out: Dict[str, Any] = {}
for cat in WORD_CATEGORIES:
block = base.get(cat)
if isinstance(block, dict):
words = block.get("words") if isinstance(block.get("words"), list) else []
analysis = str(block.get("analysis") or "")
else:
words, analysis = [], ""
out[cat] = {"words": list(words), "analysis": analysis}
return out
def _category_pool_count_map(
word_freq: Sequence[Tuple[str, int]],
) -> Tuple[List[Tuple[str, int]], Dict[str, Tuple[str, int]], Set[str]]:
top = list(word_freq[:WORD_CATEGORY_CLASSIFY_N])
count_map = {w.lower(): (w, c) for w, c in top}
keys = set(count_map.keys())
return top, count_map, keys
def _valid_category_words_in_pool(
block: Any,
count_map: Dict[str, Tuple[str, int]],
top_keys: Set[str],
) -> List[str]:
if not isinstance(block, dict):
return []
out: List[str] = []
seen: Set[str] = set()
for w in block.get("words") or []:
key = str(w).lower().strip()
if not key or key in seen or key not in top_keys:
continue
en, cnt = count_map[key]
if cnt <= 0:
continue
seen.add(key)
out.append(en)
return out
def _unclassified_pool_words(
category_data: Dict[str, Any],
word_freq: Sequence[Tuple[str, int]],
) -> List[str]:
"""Top{WORD_CATEGORY_CLASSIFY_N} 中尚未归入任何类别的词(按词频降序)。"""
top, count_map, _top_keys = _category_pool_count_map(word_freq)
classified = set(_build_word_category_map(category_data).keys())
return [en for en, _ in top if en.lower() not in classified]
def _parse_word_assign_json(text: str) -> Dict[str, List[str]]:
"""解析逐词分类结果:{{"word": ["类别", ...], ...}},空数组表示不分类。"""
text = _strip_think(text).replace("```json", "").replace("```", "").strip()
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
return {}
try:
obj = json.loads(text[start : end + 1])
except json.JSONDecodeError:
obj, _ = _loads_json_object_loose(text[start : end + 1])
if not isinstance(obj, dict):
return {}
out: Dict[str, List[str]] = {}
for word, cats in obj.items():
w = str(word).strip()
if not w:
continue
if isinstance(cats, list):
out[w] = [str(c).strip() for c in cats if str(c).strip()]
elif isinstance(cats, str) and cats.strip():
out[w] = [cats.strip()]
else:
out[w] = []
return out
def _build_word_assign_prompt(
*,
industry: str,
product_name: str,
batch_words: Sequence[str],
word_freq: Sequence[Tuple[str, int]],
) -> Tuple[str, str]:
_top, count_map, _top_keys = _category_pool_count_map(word_freq)
lines: List[str] = []
for w in batch_words:
key = w.lower()
cnt = count_map.get(key, (w, 0))[1]
lines.append(f"{w}\t{cnt}")
cats_literal = "、".join(WORD_CATEGORIES)
return build_word_assign_prompts(
industry=industry,
product_name=product_name,
word_lines="\n".join(lines),
cats_literal=cats_literal,
)
def _merge_word_assignments(
category_data: Dict[str, Any],
assignments: Dict[str, List[str]],
word_freq: Sequence[Tuple[str, int]],
) -> Dict[str, Any]:
_top, count_map, top_keys = _category_pool_count_map(word_freq)
out = _ensure_word_category_skeleton(category_data)
cat_set = set(WORD_CATEGORIES)
for word, cats in assignments.items():
key = str(word).lower().strip()
if not key or key not in top_keys:
continue
en, cnt = count_map[key]
if cnt <= 0:
continue
valid_cats = [c for c in cats if c in cat_set]
if not valid_cats:
continue
for cat in valid_cats:
block = out[cat]
existing = _valid_category_words_in_pool(block, count_map, top_keys)
if any(e.lower() == key for e in existing):
continue
block["words"] = list(existing) + [en]
return out
def _classify_remaining_words_in_pool(
category_data: Dict[str, Any],
word_freq: Sequence[Tuple[str, int]],
*,
industry: str,
product_name: str,
api_key: str,
) -> Dict[str, Any]:
"""对 Top{WORD_CATEGORY_CLASSIFY_N} 中尚未分类的词分批逐词尽量分类(可不归类)。"""
data = _ensure_word_category_skeleton(category_data)
pending = _unclassified_pool_words(data, word_freq)
top_n = WORD_CATEGORY_CLASSIFY_N
classified_n = top_n - len(pending)
if not pending:
logger.info(
"词频分类:Top%s 已全部有类别(%s 个词)",
top_n,
classified_n,
)
return data
logger.info(
"词频分类:Top%s 已分类 %s 个,待逐词补充分类 %s 个(每批 %s)",
top_n,
classified_n,
len(pending),
WORD_CATEGORY_ASSIGN_BATCH_SIZE,
)
batches = [
pending[i : i + WORD_CATEGORY_ASSIGN_BATCH_SIZE]
for i in range(0, len(pending), WORD_CATEGORY_ASSIGN_BATCH_SIZE)
]
for bi, batch in enumerate(batches, start=1):
system, user = _build_word_assign_prompt(
industry=industry,
product_name=product_name,
batch_words=batch,
word_freq=word_freq,
)
raw = _call_llm_messages(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
api_key,
temperature=0.2,
max_tokens=WORD_CATEGORY_ASSIGN_MAX_TOKENS,
timeout=REPORT_LLM_TIMEOUT_SEC,
)
before_keys = set(_build_word_category_map(data).keys())
assign = _parse_word_assign_json(raw)
if assign:
data = _merge_word_assignments(data, assign, word_freq)
after_keys = set(_build_word_category_map(data).keys())
newly = len(after_keys - before_keys)
logger.info(
"词频逐词分类 第 %s/%s 批:本批 %s 词,新归类 %s 个",
bi,
len(batches),
len(batch),
newly,
)
still = _unclassified_pool_words(data, word_freq)
logger.info(
"词频分类完成:Top%s 共归类 %s 个,未分类 %s 个(词云显示为未分类)",
top_n,
top_n - len(still),
len(still),
)
return data
def _categories_needing_analysis(
category_data: Dict[str, Any],
word_freq: Sequence[Tuple[str, int]],
) -> List[str]:
"""有词条但 analysis 为空的类别。"""
_top, count_map, top_keys = _category_pool_count_map(word_freq)
data = _ensure_word_category_skeleton(category_data)
need: List[str] = []
for cat in WORD_CATEGORIES:
words = _valid_category_words_in_pool(data.get(cat), count_map, top_keys)
if words and not str(data[cat].get("analysis") or "").strip():
need.append(cat)
return need
def _parse_category_analysis_json(text: str) -> Dict[str, str]:
"""解析 {{\"类别\": \"一段中文解读\", ...}}。"""
text = _strip_think(text).replace("```json", "").replace("```", "").strip()
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
return {}
try:
obj = json.loads(text[start : end + 1])
except json.JSONDecodeError:
obj, _ = _loads_json_object_loose(text[start : end + 1])
if not isinstance(obj, dict):
return {}
out: Dict[str, str] = {}
for k, v in obj.items():
cat = str(k).strip()
if cat in WORD_CATEGORIES and isinstance(v, str) and v.strip():
out[cat] = v.strip()
return out
def _build_category_analysis_prompt(
*,
industry: str,
product_name: str,
word_freq: Sequence[Tuple[str, int]],
category_data: Dict[str, Any],
need_cats: Sequence[str],
) -> Tuple[str, str]:
_top, count_map, top_keys = _category_pool_count_map(word_freq)
data = _ensure_word_category_skeleton(category_data)
lines: List[str] = []
for cat in need_cats:
words = _valid_category_words_in_pool(data.get(cat), count_map, top_keys)[
:WORD_CATEGORY_ANALYSIS_MAX_WORDS
]
parts = []
for w in words:
key = w.lower()
cnt = count_map.get(key, (w, 0))[1]
parts.append(f"{w}({cnt})")
lines.append(f"- {cat}:{', '.join(parts) if parts else '(无)'}")
cats_literal = "、".join(need_cats)
return build_category_analysis_prompts(
industry=industry,
product_name=product_name,
category_lines="\n".join(lines),
cats_literal=cats_literal,
)
def _merge_category_analysis(
category_data: Dict[str, Any],
analyses: Dict[str, str],
) -> Dict[str, Any]:
out = _ensure_word_category_skeleton(category_data)
for cat, text in analyses.items():
if cat not in WORD_CATEGORIES:
continue
if text.strip() and not str(out[cat].get("analysis") or "").strip():
out[cat]["analysis"] = text.strip()
return out
def _fill_category_analysis(
category_data: Dict[str, Any],
word_freq: Sequence[Tuple[str, int]],
*,
industry: str,
product_name: str,
api_key: str,
) -> Dict[str, Any]:
"""为有词但缺少 analysis 的类别自动生成简短中文解读。"""
data = _ensure_word_category_skeleton(category_data)
need = _categories_needing_analysis(data, word_freq)
if not need:
return data
logger.info("词频分类:为 %s 个类别补写 analysis: %s", len(need), need)
system, user = _build_category_analysis_prompt(
industry=industry,
product_name=product_name,
word_freq=word_freq,
category_data=data,
need_cats=need,
)
for attempt in range(2):
raw = _call_llm_messages(
[{"role": "system", "content": system}, {"role": "user", "content": user}],
api_key,
temperature=0.3,
max_tokens=WORD_CATEGORY_ANALYSIS_MAX_TOKENS,
timeout=REPORT_LLM_TIMEOUT_SEC,
)
parsed = _parse_category_analysis_json(raw)
if parsed:
data = _merge_category_analysis(data, parsed)
still = _categories_needing_analysis(data, word_freq)
if not still:
logger.info("词频分类各类 analysis 已补全")
return data
need = still
user = _build_category_analysis_prompt(
industry=industry,
product_name=product_name,
word_freq=word_freq,
category_data=data,
need_cats=need,
)[1]
logger.warning(
"词频 analysis 仍有 %s 类未生成(第 %s 次重试): %s",
len(still),
attempt + 1,
still,
)
logger.warning("词频分类 analysis 仍未补全: %s", still)
return data
def _build_word_category_map(category_data: Dict[str, Any]) -> Dict[str, List[str]]:
"""英文词(小写)-> 所属分类列表(按 WORD_CATEGORIES 顺序,支持一词多类)。"""
category_data = _normalize_word_category_data(category_data)
by_word: Dict[str, List[str]] = defaultdict(list)
for cat in WORD_CATEGORIES:
block = category_data.get(cat)
if not isinstance(block, dict):
continue
for w in block.get("words") or []:
key = str(w).lower().strip()
if key and cat not in by_word[key]:
by_word[key].append(cat)
return dict(by_word)
def _wordcloud_color_for_categories(categories: List[str]) -> str:
if not categories:
return WORDCLOUD_UNCATEGORIZED_COLOR
return CATEGORY_COLORS.get(categories[0], WORDCLOUD_UNCATEGORIZED_COLOR)
def _render_wordcloud_legend() -> str:
items = "".join(
f''
f''
f"{html.escape(c)}"
for c in WORD_CATEGORIES
)
items += (
f''
f''
f"未分类"
)
return f'
{items}
'
def _wordcloud_data(
word_freq: List[Tuple[str, int]],
word_zh: Dict[str, str],
category_data: Dict[str, Any],
) -> List[dict]:
items = word_freq[:WORDCLOUD_TOP_N]
if not items:
return []
word_cats = _build_word_category_map(category_data)
counts = [c for _, c in items]
c_min, c_max = min(counts), max(counts)
span = c_max - c_min
out: List[dict] = []
for rank, (word, count) in enumerate(items, start=1):
norm = 1.0 if span <= 0 else (count - c_min) / span
value = max(1, int((norm**WORDCLOUD_SIZE_POWER) * 1000))
zh = _zh_for_word(word, word_zh)
cats = word_cats.get(word.lower(), [])
color = _wordcloud_color_for_categories(cats)
out.append(
{
"name": word,
"value": value,
"count": count,
"rank": rank,
"en": word,
"zh": zh,
"category": cats[0] if cats else "",
"categories": cats,
"textStyle": {"color": color},
}
)
return out
def _freq_rank_map(word_freq: List[Tuple[str, int]], n: int) -> Dict[str, int]:
return {w.lower(): i + 1 for i, (w, _) in enumerate(word_freq[:n])}
def _category_top_words(
category_data: Dict[str, Any],
word_freq: List[Tuple[str, int]],
*,
top_n: int = 10,
) -> Dict[str, List[Tuple[str, int, str]]]:
category_data = _normalize_word_category_data(category_data)
count_map = {w.lower(): (w, c) for w, c in word_freq}
rank_map = _freq_rank_map(word_freq, WORD_CATEGORY_CLASSIFY_N)
out: Dict[str, List[Tuple[str, int, str]]] = {}
for cat in WORD_CATEGORIES:
block = category_data.get(cat)
if not isinstance(block, dict):
out[cat] = []
continue
words = block.get("words") or []
rows: List[Tuple[str, int, str]] = []
seen: set[str] = set()
for w in words:
key = str(w).lower().strip()
if not key or key in seen:
continue
seen.add(key)
if key in count_map:
en, cnt = count_map[key]
else:
en, cnt = str(w), 0
if cnt <= 0:
continue
rows.append((en, cnt, str(rank_map.get(key, ""))))
rows.sort(key=lambda x: (-x[1], x[0]))
out[cat] = rows[:top_n]
return out
def _render_category_blocks(
category_data: Dict[str, Any],
category_words: Dict[str, List[Tuple[str, int, str]]],
word_zh: Dict[str, str],
) -> str:
category_data = _normalize_word_category_data(category_data)
parts = [
'
',
"
词频分类洞察
",
]
for cat in WORD_CATEGORIES:
block = category_data.get(cat)
analysis = ""
if isinstance(block, dict):
analysis = str(block.get("analysis") or "").strip()
rows = category_words.get(cat, [])
parts.append(f'
{html.escape(cat)}
')
if analysis:
parts.append(f"
{html.escape(analysis)}
")
if rows:
parts.append(
"
排名
英文
中文
次数
"
)
for en, cnt, rank in rows:
zh = _zh_for_word(en, word_zh)
rank_cell = rank if rank else "—"
parts.append(
f"
{html.escape(str(rank_cell))}
"
f"
{html.escape(en)}
{html.escape(zh)}
"
f"
{cnt}
"
)
parts.append("
")
else:
parts.append('
(本类暂无词条)
')
parts.append("
")
parts.append("
")
return "\n".join(parts)
def _render_freq_table_pages(
word_freq: List[Tuple[str, int]], word_zh: Dict[str, str]
) -> str:
top = word_freq[:WORD_FREQ_TABLE_N]
total = sum(c for _, c in top) or 1
max_cnt = max((c for _, c in top), default=1)
row_lines: List[str] = []
for rank, (en, cnt) in enumerate(top, start=1):
page = (rank - 1) // WORD_FREQ_PAGE_SIZE + 1
zh = _zh_for_word(en, word_zh)
pct = cnt / total * 100.0
bar_w = max(4, int(cnt / max_cnt * 100))
row_lines.append(
f'
'
f"
{rank}
"
f'
{html.escape(en)}
'
f"
{html.escape(zh)}
"
f'
'
f"{cnt}
"
f"
{pct:.2f}%
"
)
n_pages = min(
WORD_FREQ_PAGES,
(len(top) + WORD_FREQ_PAGE_SIZE - 1) // WORD_FREQ_PAGE_SIZE or 1,
)
tabs = "".join(
f''
for i in range(n_pages)
)
body = f"""{tabs}