amz_review_analyse/结构化_server.py
OnesvmWhoops ae809a6006 扩展产品反馈八类分类,并新增业务说明文档。
将 category 从 6 类扩展为 8 类标准枚举并允许补充自定义类别,同步更新提取规则与校验映射;新增面向业务的 ECharts 流程说明页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 15:55:14 +08:00

1223 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
VOC 评论结构化服务(命令行 / 直接调用,无 MCP)。
----------------------------------------------------------------------
虚拟环境(推荐 Python 3.10+)
cd "/Users/onesvmwhoops/Cursor_Project/VOC_LLM结构化"
source 310py/bin/activate
pip install -r requirements.txt
----------------------------------------------------------------------
运行前提供 DeepSeek 密钥(任选其一;勿把密钥写进代码仓库)::
export DEEPSEEK_API_KEY="sk-xxx"
# 或项目根单行文件 .deepseek_key
# 或 export DEEPSEEK_API_KEY_FILE="/path/to/key.txt"
----------------------------------------------------------------------
用法::
./310py/bin/python 结构化_server.py --industry "Pet supplements" --product "Turkey tail mushroom for dogs" --file merged_reviews_cleaned.csv
./310py/bin/python 结构化_server.py --smoke
模型:默认 ``deepseek-v4-pro``(思考关闭;DeepSeek OpenAI 兼容 Chat API;可通过 ``DEEPSEEK_MODEL`` 覆盖)。
数据库:项目根目录 ``voc_structured.sqlite``;每次写入前会清理该库及下游 ``voc_embeddings.sqlite``、``voc_clustering.sqlite``(冒烟测试用临时库时不清理项目根文件)。
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import logging
import os
import re
import sqlite3
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from csv import DictReader
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Tuple
from prompts.loader import product_feedback_categories
from voc_llm import CHAT_MODEL, chat_extra_body, create_chat_client, require_chat_api_key
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
stream=sys.stderr,
)
logger = logging.getLogger("voc_structured")
PROJECT_ROOT = Path(__file__).resolve().parent
DB_PATH = PROJECT_ROOT / "voc_structured.sqlite"
EMBED_DB = PROJECT_ROOT / "voc_embeddings.sqlite"
CLUSTER_DB = PROJECT_ROOT / "voc_clustering.sqlite"
MODEL_NAME = CHAT_MODEL
REVIEW_COLUMN_NAMES = ("content", "review", "评论")
BATCH_FETCH_MAX_ATTEMPTS = 3
BATCH_CORRECTION_MAX_ATTEMPTS = 3
_VALID_CATEGORIES = product_feedback_categories() # 模块加载时快照;校验时用 _get_valid_categories()
def _get_valid_categories() -> frozenset[str]:
return product_feedback_categories()
# 模型上下文上限;动态分批受 DEFAULT_MAX_BATCH_INPUT_TOKENS 与 DEFAULT_MAX_BATCH_REVIEWS 约束
MODEL_MAX_INPUT_TOKENS = 1000_800
MODEL_MAX_OUTPUT_TOKENS = 200_530
CHARS_PER_TOKEN_EST = 3.2
DEFAULT_MAX_BATCH_INPUT_TOKENS = 200_000
# 仅用于 batch_plan 日志中的输出 token 粗估,不参与分批与 API max_tokens
DEFAULT_OUTPUT_TOKENS_PER_REVIEW = 450
BATCH_COUNT_MIN = 1
# 动态分批时单批评论条数上限(避免单请求过大导致输出截断)
DEFAULT_MAX_BATCH_REVIEWS = 100
# Chat 批间并行;与 embedding 共用账号时不宜过高,避免连带 429
STRUCT_DEFAULT_WORKERS = 20
def _resolve_max_batch_reviews(explicit: int | None = None) -> int:
if explicit is not None and explicit > 0:
return explicit
env = os.environ.get("VOC_STRUCT_BATCH_MAX_REVIEWS", "").strip()
if env.isdigit() and int(env) > 0:
return int(env)
return DEFAULT_MAX_BATCH_REVIEWS
def _resolve_struct_workers(explicit: int | None = None) -> int:
if explicit is not None and explicit > 0:
return explicit
env = os.environ.get("VOC_STRUCT_WORKERS", "").strip()
if env.isdigit() and int(env) > 0:
return int(env)
return STRUCT_DEFAULT_WORKERS
def _api_max_output_tokens() -> int:
"""单次 Chat 请求 max_tokens:使用模型上限,不按条数折算。"""
return MODEL_MAX_OUTPUT_TOKENS
def _load_prompt_module():
path = PROJECT_ROOT / "结构化_Prompt.py"
spec = importlib.util.spec_from_file_location("voc_structured_prompts", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load prompt module from {path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
_PROMPTS = _load_prompt_module()
def _clean_voc_sqlite_databases(structured_db: Path) -> None:
"""写入前删除 SQLite,避免与旧 job / 下游向量、聚类结果混用。"""
paths = [structured_db.resolve()]
if structured_db.resolve() == DB_PATH.resolve():
paths.extend([EMBED_DB.resolve(), CLUSTER_DB.resolve()])
for p in paths:
if p.is_file():
p.unlink()
logger.info("已清理 SQLite: %s", p.name)
def init_sqlite_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS analysis_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
industry TEXT NOT NULL,
product_name TEXT NOT NULL,
source_file TEXT NOT NULL,
batch_size INTEGER NOT NULL,
model TEXT NOT NULL,
full_result_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comment_extractions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
source_row INTEGER NOT NULL,
batch_index INTEGER NOT NULL,
comment_key TEXT NOT NULL,
extraction_json TEXT NOT NULL,
UNIQUE(job_id, source_row),
FOREIGN KEY (job_id) REFERENCES analysis_jobs(id)
);
"""
)
def load_reviews_from_file(file_path: str) -> List[Tuple[int, str]]:
"""
返回 (source_row, text) 列表。
CSV:source_row 为表头下的数据行序号(第 1 行数据=1),空单元格跳过。
TXT:source_row 为文件物理行号(从 1 起),空行跳过。
"""
p = Path(file_path).expanduser().resolve()
if not p.is_file():
raise FileNotFoundError(f"File not found: {p}")
suffix = p.suffix.lower()
if suffix == ".csv":
return _load_csv_reviews(p)
if suffix == ".txt" or suffix == ".text":
return _load_txt_reviews(p)
raise ValueError(f"Unsupported file type {suffix!r}; use .csv or .txt")
def _load_txt_reviews(p: Path) -> List[Tuple[int, str]]:
out: List[Tuple[int, str]] = []
with p.open(encoding="utf-8-sig", newline="") as f:
for line_no, line in enumerate(f, start=1):
t = line.strip("\r\n").strip()
if not t:
continue
out.append((line_no, t))
return out
def _load_csv_reviews(p: Path) -> List[Tuple[int, str]]:
with p.open(encoding="utf-8-sig", newline="") as f:
reader = DictReader(f)
if not reader.fieldnames:
raise ValueError("CSV has no header row")
headers = [h.strip() for h in reader.fieldnames]
lower_map = {h.lower(): h for h in headers}
col: str | None = None
for name in REVIEW_COLUMN_NAMES:
if name.lower() in lower_map:
col = lower_map[name.lower()]
break
if col is None:
if len(headers) == 1:
col = headers[0]
else:
raise ValueError(
"CSV must contain column 'content', 'review', or '评论', "
f"or be single-column. Found columns: {headers}"
)
rows = list(reader)
out: List[Tuple[int, str]] = []
for idx, row in enumerate(rows, start=1):
cell = row.get(col)
if cell is None:
cell = ""
t = str(cell).strip()
if not t:
continue
out.append((idx, t))
return out
def _chunked(items: List[Tuple[int, str]], size: int) -> List[List[Tuple[int, str]]]:
return [items[i : i + size] for i in range(0, len(items), size)]
def _estimate_tokens(text: str) -> int:
"""英文评论粗略估 token(偏保守,避免顶满上下文)。"""
return max(1, int(len(text) / CHARS_PER_TOKEN_EST))
def _estimate_batch_input_tokens(
industry: str,
product_name: str,
batch: List[Tuple[int, str]],
) -> int:
tagged, keys, _ = _format_tagged_batch(batch)
system, user = _PROMPTS.build_batch_review_analysis_prompts(
industry=industry,
product_name=product_name,
keys=keys,
tagged_input=tagged,
)
return _estimate_tokens(system) + _estimate_tokens(user)
def _estimate_batch_output_tokens(review_count: int) -> int:
return review_count * DEFAULT_OUTPUT_TOKENS_PER_REVIEW
def chunk_reviews_by_token_budget(
reviews: List[Tuple[int, str]],
industry: str,
product_name: str,
*,
max_input_tokens: int = DEFAULT_MAX_BATCH_INPUT_TOKENS,
max_reviews_per_batch: int = DEFAULT_MAX_BATCH_REVIEWS,
) -> List[List[Tuple[int, str]]]:
"""
按预估输入 token 将评论动态打包。
单批条数不超过 max_reviews_per_batch;输入 token 超过 max_input_tokens 时拆批。
单条过长则单独成批。
"""
max_input_tokens = min(max_input_tokens, MODEL_MAX_INPUT_TOKENS)
max_reviews_per_batch = max(BATCH_COUNT_MIN, max_reviews_per_batch)
batches: List[List[Tuple[int, str]]] = []
i = 0
while i < len(reviews):
batch: List[Tuple[int, str]] = []
while i < len(reviews):
candidate = batch + [reviews[i]]
inp = _estimate_batch_input_tokens(industry, product_name, candidate)
if batch and inp > max_input_tokens:
break
batch = candidate
i += 1
if inp > max_input_tokens:
logger.warning(
"单条评论过长(约 %s input tokens),单独成批: source_row=%s",
inp,
batch[0][0],
)
break
if len(batch) >= max_reviews_per_batch:
break
if not batch:
break
batches.append(batch)
return batches
def _summarize_batch_plan(
batches: List[List[Tuple[int, str]]],
industry: str,
product_name: str,
) -> List[Dict[str, Any]]:
plan: List[Dict[str, Any]] = []
for idx, batch in enumerate(batches):
chars = sum(len(t) for _, t in batch)
plan.append(
{
"batch_index": idx,
"review_count": len(batch),
"total_chars": chars,
"est_input_tokens": _estimate_batch_input_tokens(
industry, product_name, batch
),
"est_output_tokens": _estimate_batch_output_tokens(len(batch)),
}
)
return plan
def _format_tagged_batch(batch: List[Tuple[int, str]]) -> Tuple[str, List[str], Dict[str, int]]:
"""返回 tagged 文本、键列表 C1..Cn、Ci -> source_row。"""
lines: List[str] = []
keys: List[str] = []
key_to_row: Dict[str, int] = {}
for i, (source_row, text) in enumerate(batch, start=1):
key = f"C{i}"
keys.append(key)
key_to_row[key] = source_row
safe = text.replace("\r\n", "\n").replace("\r", "\n")
lines.append(f"[{key}] {safe}")
return "\n".join(lines), keys, key_to_row
def _strip_code_fence(text: str) -> str:
t = text.strip()
if t.startswith("```"):
lines = t.split("\n")
if len(lines) >= 2 and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
t = "\n".join(lines).strip()
return t
def parse_model_json(raw: str) -> Any:
t = _strip_code_fence(raw)
try:
return json.loads(t)
except json.JSONDecodeError:
m = re.search(r"[\{\[][\s\S]*[\}\]]\s*$", t)
if not m:
raise
return json.loads(m.group(0))
def parse_model_json_object(raw: str) -> Dict[str, Any]:
parsed = parse_model_json(raw)
if isinstance(parsed, dict):
return parsed
raise ValueError(
f"模型 JSON 应为对象,实际为 {type(parsed).__name__};请使用批量对象格式 {{\"C1\": ...}}"
)
def _call_dashscope_chat(
messages: List[Dict[str, str]],
*,
max_tokens: int | None = None,
) -> Any:
require_chat_api_key()
client = create_chat_client()
extra_body = chat_extra_body(MODEL_NAME)
out_tokens = (
_api_max_output_tokens()
if max_tokens is None
else min(MODEL_MAX_OUTPUT_TOKENS, max_tokens)
)
resp = client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
response_format={"type": "json_object"},
temperature=0.2,
max_tokens=out_tokens,
**({"extra_body": extra_body} if extra_body else {}),
)
msg = resp.choices[0].message
choice = msg.content
if not choice and getattr(msg, "reasoning_content", None):
choice = msg.reasoning_content
if not choice:
raise RuntimeError("Empty model response")
return parse_model_json(choice)
def _call_dashscope_model(system: str, user: str) -> Dict[str, Any]:
return _call_dashscope_chat(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
]
)
def _coalesce_batch_response(raw: Dict[str, Any], keys: List[str]) -> Dict[str, Any]:
"""若模型把 C1..Cn 包在嵌套对象里,尝试展开到顶层。"""
if any(k in raw for k in keys):
return raw
for value in raw.values():
if isinstance(value, dict) and any(k in value for k in keys):
return value
return raw
def _normalize_batch_response(raw: Any, keys: List[str]) -> Dict[str, Any]:
"""
统一为 {C1: {...}, C2: {...}}。
兼容模型误返回 JSON 数组、嵌套对象或按序号的对象。
"""
if isinstance(raw, list):
out: Dict[str, Any] = {}
for i, key in enumerate(keys):
if i >= len(raw):
break
item = raw[i]
if not isinstance(item, dict):
continue
if len(item) == 1:
sole_k, sole_v = next(iter(item.items()))
if sole_k in keys and isinstance(sole_v, dict):
out[sole_k] = sole_v
continue
if any(k in item for k in keys):
for k in keys:
if k in item and isinstance(item[k], dict):
out[k] = item[k]
continue
out[key] = item
if out:
return out
raise ValueError(
"模型返回 JSON 数组,但无法按顺序映射为 "
+ ", ".join(keys[: min(len(raw), len(keys))])
)
if not isinstance(raw, dict):
raise TypeError(f"批量 JSON 应为 object 或 array,实际为 {type(raw).__name__}")
coalesced = _coalesce_batch_response(raw, keys)
if any(k in coalesced for k in keys):
return coalesced
# 如 {"1": {...}, "2": {...}} 映射到 C1, C2
remapped: Dict[str, Any] = {}
for i, key in enumerate(keys, start=1):
for alias in (str(i), f"C{i}"):
if alias in coalesced and isinstance(coalesced[alias], dict):
remapped[key] = coalesced[alias]
break
if remapped:
return remapped
return coalesced
_SENTIMENT_CANONICAL = {
"positive": "Positive",
"negative": "Negative",
"neutral": "Neutral",
}
# 模型常见误写/自创词 -> 合法 sentiment 键(校验前自动映射,减少无效重试)
_SENTIMENT_ALIASES: Dict[str, str] = {
"mixed": "neutral",
"ambivalent": "neutral",
"ambiguous": "neutral",
"both": "neutral",
"balanced": "neutral",
"unclear": "neutral",
"unsure": "neutral",
"unknown": "neutral",
"pos": "positive",
"positve": "positive",
"positiv": "positive",
"neg": "negative",
"neu": "neutral",
"somewhat positive": "positive",
"slightly positive": "positive",
"mildly positive": "positive",
"somewhat negative": "negative",
"slightly negative": "negative",
"mildly negative": "negative",
}
# 模型常见误写 -> 8 个标准 category(校验前自动映射,减少无效重试)
_CATEGORY_ALIASES: Dict[str, str] = {
# Price(价格)
"value": "Price",
"values": "Price",
"cost": "Price",
"pricing": "Price",
"value for money": "Price",
"worth": "Price",
# Logistics(物流)
"packaging": "Logistics",
"shipping": "Logistics",
"delivery": "Logistics",
# Customer Service(客服)
"service": "Customer Service",
"customer support": "Customer Service",
"support": "Customer Service",
# Appearance(外观)
"design": "Appearance",
"look": "Appearance",
# Function(功效/功能)
"efficacy": "Function",
"performance": "Function",
"effectiveness": "Function",
"effect": "Function",
# Ingredient(原料)
"ingredients": "Ingredient",
"material": "Ingredient",
"materials": "Ingredient",
"formula": "Ingredient",
# Trust(信任)
"reliability": "Trust",
"reliable": "Trust",
"brand": "Trust",
"authenticity": "Trust",
"genuine": "Trust",
"trustworthy": "Trust",
}
def _canonical_sentiment_key(raw: Any) -> str | None:
key = str(raw or "").strip().lower()
if not key:
return None
if key in _SENTIMENT_CANONICAL:
return key
return _SENTIMENT_ALIASES.get(key)
def _normalize_sentiment(raw: Any) -> str:
key = _canonical_sentiment_key(raw)
if key is None:
return "Neutral"
return _SENTIMENT_CANONICAL[key]
def _normalize_category(raw: Any) -> str | None:
valid = _get_valid_categories()
key = str(raw or "").strip()
if not key:
return None
if key in valid:
return key
mapped = _CATEGORY_ALIASES.get(key.lower())
if mapped:
return mapped
lower = key.lower()
for v in valid:
if v.lower() == lower:
return v
# 不属于 8 个标准类别:允许模型自行补充新类别,规范为首字母大写后保留
return key.title()
def _normalize_product_feedback(items: Any) -> List[Dict[str, str]]:
if not isinstance(items, list):
return []
out: List[Dict[str, str]] = []
for item in items:
if not isinstance(item, dict):
continue
aspect = str(item.get("aspect", "")).strip()
opinion = str(item.get("opinion", "")).strip()
category = _normalize_category(item.get("category"))
if aspect and opinion and category:
out.append(
{
"aspect": aspect,
"opinion": opinion,
"sentiment": _normalize_sentiment(item.get("sentiment")),
"category": category,
}
)
return out
def _validate_extraction_strict(obj: Any, key_label: str = "") -> Dict[str, Any]:
"""校验结构化结果;失败时抛出带键名的 ValueError,供回传模型修正。"""
prefix = f"{key_label}: " if key_label else ""
if not isinstance(obj, dict):
raise ValueError(f"{prefix}必须是 JSON 对象")
for field in ("audience", "pain_points", "product_feedback"):
if field not in obj:
raise ValueError(f"{prefix}缺少必填字段 {field}")
audience = obj.get("audience")
if not isinstance(audience, str) or not audience.strip():
raise ValueError(f"{prefix}audience 必须为非空字符串")
pain_points = obj.get("pain_points")
if not isinstance(pain_points, list):
raise ValueError(f"{prefix}pain_points 必须为数组")
for i, p in enumerate(pain_points):
if not isinstance(p, str) or not str(p).strip():
raise ValueError(f"{prefix}pain_points[{i}] 必须为非空字符串")
pf = obj.get("product_feedback")
if not isinstance(pf, list):
raise ValueError(f"{prefix}product_feedback 必须为数组")
for i, item in enumerate(pf):
if not isinstance(item, dict):
raise ValueError(f"{prefix}product_feedback[{i}] 必须是对象")
for sub in ("aspect", "opinion", "sentiment", "category"):
if not str(item.get(sub, "")).strip():
raise ValueError(f"{prefix}product_feedback[{i}] 缺少或空的 {sub}")
if _canonical_sentiment_key(item.get("sentiment")) is None:
raise ValueError(
f"{prefix}product_feedback[{i}].sentiment 必须为 "
"Positive、Negative 或 Neutral(禁止 Mixed 等自创词)"
)
raw_cat = str(item.get("category", "")).strip()
cat = _normalize_category(raw_cat)
if cat is None:
raise ValueError(
f"{prefix}product_feedback[{i}].category 非法: {raw_cat!r},"
f"允许: {', '.join(sorted(_get_valid_categories()))}"
)
return _normalize_extraction(obj)
def _normalize_extraction(obj: Any) -> Dict[str, Any]:
"""补齐缺省字段,避免模型漏写 pain_points / product_feedback 导致整条丢弃。"""
if not isinstance(obj, dict):
raise ValueError("Each extraction must be a JSON object")
audience = obj.get("audience", "unknown")
if not isinstance(audience, str) or not str(audience).strip():
audience = "unknown"
else:
audience = str(audience).strip()
pain_points = obj.get("pain_points", [])
if pain_points is None:
pain_points = []
if not isinstance(pain_points, list):
pain_points = [str(pain_points)] if str(pain_points).strip() else []
pain_points = [str(p).strip() for p in pain_points if str(p).strip()]
product_feedback = _normalize_product_feedback(obj.get("product_feedback", []))
missing = [
f
for f in ("audience", "pain_points", "product_feedback")
if f not in obj
]
if missing:
logger.info("Normalized missing fields %s in extraction", missing)
return {
"audience": audience,
"pain_points": pain_points,
"product_feedback": product_feedback,
}
def _build_batch_correction_message(keys: List[str], errors: List[str]) -> str:
keys_literal = ", ".join(json.dumps(k) for k in keys)
err_block = "\n".join(f"- {e}" for e in errors)
return (
"你上一次返回的 JSON 未通过服务端校验,请根据下列错误修正后重新输出。\n\n"
f"校验错误:\n{err_block}\n\n"
"要求:\n"
f"- 输出一个 JSON 对象,顶层键必须且仅能是:{keys_literal}\n"
"- 每个键的值必须包含 audience、pain_points、product_feedback\n"
"- product_feedback 每条须含 aspect、opinion、sentiment"
"(仅 Positive/Negative/Neutral,禁止 Mixed/Ambiguous;褒贬交织选主倾向或拆条)、"
"category(禁止 Value,性价比用 Price)\n"
"- 只输出一个 JSON 对象(不要用数组),不要 markdown 代码围栏或解释文字"
)
def _parse_batch_with_model_correction(
system: str,
user: str,
sub_keys: List[str],
*,
batch_index: int,
attempt_label: str,
) -> Tuple[Dict[str, Any], Dict[str, Dict[str, Any]], List[str]]:
"""
调用模型并在校验失败时将错误信息附在对话中重试。
返回 (原始 JSON, 已通过校验的条目, 仍失败的错误列表)。
"""
messages: List[Dict[str, str]] = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
raw_obj: Dict[str, Any] = {}
ok: Dict[str, Dict[str, Any]] = {}
errors: List[str] = []
for correction in range(BATCH_CORRECTION_MAX_ATTEMPTS):
logger.info(
"请求模型 batch %s(%s),校验轮次 %s/%s…",
batch_index,
attempt_label,
correction + 1,
BATCH_CORRECTION_MAX_ATTEMPTS,
)
try:
raw_parsed = _call_dashscope_chat(
messages, max_tokens=_api_max_output_tokens()
)
raw_obj = _normalize_batch_response(raw_parsed, sub_keys)
except (RuntimeError, json.JSONDecodeError, ValueError, TypeError) as e:
errors = [f"模型响应无法解析为 JSON: {e}"]
ok = {}
if correction + 1 >= BATCH_CORRECTION_MAX_ATTEMPTS:
break
logger.warning(
"Batch %s parse error (correction %s): %s",
batch_index,
correction + 1,
e,
)
messages.append(
{
"role": "assistant",
"content": json.dumps(
{"error": "invalid_json", "detail": str(e)},
ensure_ascii=False,
),
}
)
messages.append(
{
"role": "user",
"content": _build_batch_correction_message(sub_keys, errors),
}
)
continue
ok = {}
errors = []
for sub_ck in sub_keys:
if sub_ck not in raw_obj:
errors.append(f"键 {sub_ck}:响应 JSON 缺少该顶层键")
continue
try:
ok[sub_ck] = _validate_extraction_strict(raw_obj[sub_ck], sub_ck)
except ValueError as e:
errors.append(str(e))
if not errors:
return raw_obj, ok, []
if correction + 1 >= BATCH_CORRECTION_MAX_ATTEMPTS:
break
logger.warning(
"Batch %s validation failed (correction %s/%s): %s",
batch_index,
correction + 1,
BATCH_CORRECTION_MAX_ATTEMPTS,
errors,
)
messages.append(
{"role": "assistant", "content": json.dumps(raw_obj, ensure_ascii=False)}
)
messages.append(
{
"role": "user",
"content": _build_batch_correction_message(sub_keys, errors),
}
)
return raw_obj, ok, errors
def _fetch_batch_extractions(
industry: str,
product_name: str,
batch: List[Tuple[int, str]],
b_idx: int,
) -> Dict[str, Any]:
"""请求一批评论的结构化结果;对缺失/无效键自动缩小批次重试。"""
_, all_keys, key_to_row = _format_tagged_batch(batch)
row_to_text = {row: text for row, text in batch}
pending_keys: List[str] = list(all_keys)
merged: Dict[str, Any] = {}
for attempt in range(BATCH_FETCH_MAX_ATTEMPTS):
if not pending_keys:
break
sub_batch = [
(key_to_row[ck], row_to_text[key_to_row[ck]]) for ck in pending_keys
]
tagged, sub_keys, sub_key_to_row = _format_tagged_batch(sub_batch)
# 重试时子批次会重新编号为 C1..Cn,需映射回原始键名
sub_to_orig = {sub_keys[i]: pending_keys[i] for i in range(len(sub_keys))}
system, user = _PROMPTS.build_batch_review_analysis_prompts(
industry=industry,
product_name=product_name,
keys=sub_keys,
tagged_input=tagged,
)
logger.info(
"请求模型 batch %s,第 %s 次尝试,本批 %s 条…",
b_idx,
attempt + 1,
len(sub_keys),
)
_, ok_map, val_errors = _parse_batch_with_model_correction(
system,
user,
sub_keys,
batch_index=b_idx,
attempt_label=f"attempt {attempt + 1}",
)
next_pending: List[str] = []
for sub_ck in sub_keys:
orig_ck = sub_to_orig[sub_ck]
if sub_ck in ok_map:
merged[orig_ck] = ok_map[sub_ck]
else:
next_pending.append(orig_ck)
if val_errors:
logger.warning(
"Batch %s still has validation issues after correction (attempt %s): %s",
b_idx,
attempt + 1,
val_errors,
)
pending_keys = next_pending
if pending_keys and attempt + 1 < BATCH_FETCH_MAX_ATTEMPTS:
logger.info(
"Retrying %s review(s) in batch %s (attempt %s)",
len(pending_keys),
b_idx,
attempt + 2,
)
if pending_keys:
logger.warning(
"Batch %s: gave up on %s review(s) after %s attempts: %s",
b_idx,
len(pending_keys),
BATCH_FETCH_MAX_ATTEMPTS,
pending_keys,
)
return merged
def _apply_batch_extraction_result(
*,
b_idx: int,
batch: List[Tuple[int, str]],
raw_obj: Dict[str, Any],
extractions_by_row: Dict[str, Dict[str, Any]],
sqlite_rows: List[Tuple[int, int, str, Dict[str, Any]]],
batch_details: List[Dict[str, Any]],
total_batches: int,
) -> None:
_, keys, key_to_row = _format_tagged_batch(batch)
logger.info(
"批次 %s/%s 完成:成功 %s/%s 条",
b_idx + 1,
total_batches,
len(raw_obj),
len(batch),
)
for ck in keys:
if ck not in raw_obj:
continue
validated = raw_obj[ck]
src_row = key_to_row[ck]
extractions_by_row[str(src_row)] = validated
sqlite_rows.append((src_row, b_idx, ck, validated))
batch_details.append(
{
"batch_index": b_idx,
"keys": keys,
"source_rows": [key_to_row[k] for k in keys],
"model_output": raw_obj,
}
)
def _run_struct_batches_parallel(
industry: str,
product_name: str,
batches: List[List[Tuple[int, str]]],
workers: int,
*,
extractions_by_row: Dict[str, Dict[str, Any]],
sqlite_rows: List[Tuple[int, int, str, Dict[str, Any]]],
batch_details: List[Dict[str, Any]],
) -> None:
total_batches = len(batches)
workers = max(1, min(workers, total_batches))
logger.info(
"结构化批间并行:%s 批,workers=%s(模型 %s)",
total_batches,
workers,
MODEL_NAME,
)
if workers == 1:
for b_idx, batch in enumerate(batches):
logger.info("批次 %s/%s:处理 %s 条评论…", b_idx + 1, total_batches, len(batch))
raw_obj = _fetch_batch_extractions(industry, product_name, batch, b_idx)
_apply_batch_extraction_result(
b_idx=b_idx,
batch=batch,
raw_obj=raw_obj,
extractions_by_row=extractions_by_row,
sqlite_rows=sqlite_rows,
batch_details=batch_details,
total_batches=total_batches,
)
return
done = 0
with ThreadPoolExecutor(max_workers=workers) as pool:
future_map = {
pool.submit(
_fetch_batch_extractions, industry, product_name, batch, b_idx
): (b_idx, batch)
for b_idx, batch in enumerate(batches)
}
for fut in as_completed(future_map):
b_idx, batch = future_map[fut]
raw_obj = fut.result()
done += 1
if done == total_batches or done % 5 == 0:
logger.info("结构化进度 %s/%s 批", done, total_batches)
_apply_batch_extraction_result(
b_idx=b_idx,
batch=batch,
raw_obj=raw_obj,
extractions_by_row=extractions_by_row,
sqlite_rows=sqlite_rows,
batch_details=batch_details,
total_batches=total_batches,
)
def run_analysis(
industry: str,
product_name: str,
file_path: str,
batch_size: int | None = None,
*,
clean_databases: bool = True,
max_batch_input_tokens: int = DEFAULT_MAX_BATCH_INPUT_TOKENS,
max_batch_reviews: int | None = None,
workers: int | None = None,
) -> Dict[str, Any]:
src = str(Path(file_path).expanduser().resolve())
reviews = load_reviews_from_file(src)
if not reviews:
raise ValueError("No non-empty reviews found in file")
if batch_size is not None and batch_size > 0:
if batch_size < BATCH_COUNT_MIN:
raise ValueError(f"batch_size must be >= {BATCH_COUNT_MIN}, got {batch_size}")
batches = _chunked(reviews, batch_size)
batching_mode = "fixed"
batch_size_record = batch_size
else:
cap_reviews = _resolve_max_batch_reviews(max_batch_reviews)
batches = chunk_reviews_by_token_budget(
reviews,
industry,
product_name,
max_input_tokens=max_batch_input_tokens,
max_reviews_per_batch=cap_reviews,
)
batching_mode = "dynamic"
batch_size_record = 0
batch_plan = _summarize_batch_plan(batches, industry, product_name)
total_batches = len(batches)
counts = [p["review_count"] for p in batch_plan]
if batching_mode == "dynamic" and counts:
batch_size_record = max(counts)
cap_note = ""
if batching_mode == "dynamic":
cap_note = f",单批≤{_resolve_max_batch_reviews(max_batch_reviews)}条"
logger.info(
"开始结构化:共 %s 条评论,模式=%s%s,共 %s 批,每批条数 min/med/max=%s/%s/%s",
len(reviews),
batching_mode,
cap_note,
total_batches,
min(counts) if counts else 0,
sorted(counts)[len(counts) // 2] if counts else 0,
max(counts) if counts else 0,
)
for p in batch_plan:
logger.info(
" 批次 %s: %s 条, %s 字, 约 %s in / %s out tokens",
p["batch_index"] + 1,
p["review_count"],
p["total_chars"],
p["est_input_tokens"],
p["est_output_tokens"],
)
extractions_by_row: Dict[str, Dict[str, Any]] = {}
batch_details: List[Dict[str, Any]] = []
sqlite_rows: List[Tuple[int, int, str, Dict[str, Any]]] = []
struct_workers = _resolve_struct_workers(workers)
_run_struct_batches_parallel(
industry,
product_name,
batches,
struct_workers,
extractions_by_row=extractions_by_row,
sqlite_rows=sqlite_rows,
batch_details=batch_details,
)
batch_details.sort(key=lambda x: x["batch_index"])
if not sqlite_rows:
require_chat_api_key() # 无结果时若缺 key 则给出明确错误
raise RuntimeError(
f"结构化 0/{len(reviews)} 条成功。"
"请检查 DEEPSEEK_API_KEY / .deepseek_key 与网络;"
"查看上方 WARNING 中的具体原因。"
)
created = datetime.now(timezone.utc).isoformat()
db_path = DB_PATH.resolve()
if clean_databases:
_clean_voc_sqlite_databases(db_path)
conn = sqlite3.connect(db_path)
try:
init_sqlite_schema(conn)
cur = conn.execute(
"""
INSERT INTO analysis_jobs
(created_at, industry, product_name, source_file, batch_size, model, full_result_json)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
created,
industry,
product_name,
src,
batch_size_record,
MODEL_NAME,
"{}",
),
)
job_id = int(cur.lastrowid)
for src_row, b_idx, ck, payload in sqlite_rows:
conn.execute(
"""
INSERT INTO comment_extractions
(job_id, source_row, batch_index, comment_key, extraction_json)
VALUES (?, ?, ?, ?, ?)
""",
(
job_id,
src_row,
b_idx,
ck,
json.dumps(payload, ensure_ascii=False),
),
)
result: Dict[str, Any] = {
"job_id": job_id,
"sqlite_path": str(db_path),
"industry": industry,
"product_name": product_name,
"source_file": src,
"batching_mode": batching_mode,
"batch_size": batch_size_record,
"batch_plan": batch_plan,
"max_batch_input_tokens": max_batch_input_tokens,
"api_max_output_tokens": MODEL_MAX_OUTPUT_TOKENS,
"struct_workers": struct_workers,
"model": MODEL_NAME,
"extractions": extractions_by_row,
"batches": batch_details,
}
full_json = json.dumps(result, ensure_ascii=False)
conn.execute(
"UPDATE analysis_jobs SET full_result_json = ? WHERE id = ?",
(full_json, job_id),
)
conn.commit()
finally:
conn.close()
return result
def _run_smoke_test() -> None:
"""
真实调用 DashScope 的冒烟:5 条英文评论、batch_size=5、一次模型请求。
使用临时目录下的 SQLite,不写项目根 voc_structured.sqlite。
"""
import tempfile
global DB_PATH
td = Path(tempfile.mkdtemp(prefix="voc_smoke_"))
csv_p = td / "reviews.csv"
csv_p.write_text(
"review\n"
"Soft ramp; zipper broke after one week.\n"
"Shipping box was crushed but product fine.\n"
"My senior dog uses it daily; worth the price.\n"
"Smells a bit chemical at first, smell fades.\n"
"Great for cats too, very stable.\n",
encoding="utf-8",
)
saved_db = DB_PATH
DB_PATH = td / "smoke.sqlite"
try:
out = run_analysis(
industry="Pet supplies",
product_name="Pet bed ramp",
file_path=str(csv_p),
)
slim = {
"ok": True,
"job_id": out.get("job_id"),
"model": out.get("model"),
"sqlite_path": out.get("sqlite_path"),
"extraction_row_keys": sorted(out.get("extractions", {}).keys()),
"extractions": out.get("extractions"),
}
print(json.dumps(slim, ensure_ascii=False, indent=2))
finally:
DB_PATH = saved_db
def main() -> None:
parser = argparse.ArgumentParser(
description=f"VOC 评论结构化({MODEL_NAME} + SQLite)"
)
parser.add_argument("--industry", required=True, help="行业,如 Pet supplements")
parser.add_argument("--product", required=True, help="产品名")
parser.add_argument("--file", required=True, type=Path, help="评论 CSV/TXT 路径")
parser.add_argument(
"--batch-size",
type=int,
default=None,
help="固定每批条数;不指定则按评论长度与 token 预算动态分批",
)
parser.add_argument(
"--max-batch-input-tokens",
type=int,
default=DEFAULT_MAX_BATCH_INPUT_TOKENS,
help=(
f"动态分批:单批最大输入 token 估算上限(默认 {DEFAULT_MAX_BATCH_INPUT_TOKENS};"
f"模型上限约 {MODEL_MAX_INPUT_TOKENS});"
f"单批最多 {DEFAULT_MAX_BATCH_REVIEWS} 条(环境变量 VOC_STRUCT_BATCH_MAX_REVIEWS)。"
),
)
parser.add_argument(
"--max-batch-reviews",
type=int,
default=None,
help=(
f"动态分批单批最多评论条数(默认 {DEFAULT_MAX_BATCH_REVIEWS};"
"环境变量 VOC_STRUCT_BATCH_MAX_REVIEWS)"
),
)
parser.add_argument(
"--workers",
type=int,
default=None,
help=(
f"批间并行请求数(默认 {STRUCT_DEFAULT_WORKERS};"
"1=串行;可用环境变量 VOC_STRUCT_WORKERS)"
),
)
parser.add_argument(
"-o",
"--output-json",
type=Path,
default=None,
help="可选:将完整结果 JSON 写入该文件",
)
args = parser.parse_args()
result = run_analysis(
industry=args.industry,
product_name=args.product,
file_path=str(args.file),
batch_size=args.batch_size,
max_batch_input_tokens=args.max_batch_input_tokens,
max_batch_reviews=args.max_batch_reviews,
workers=args.workers,
)
text = json.dumps(result, ensure_ascii=False, indent=2)
if args.output_json:
args.output_json.write_text(text, encoding="utf-8")
logger.info("结果已写入 %s", args.output_json)
print(text)
if __name__ == "__main__":
if len(sys.argv) >= 2 and sys.argv[1] == "--smoke":
_run_smoke_test()
else:
main()