AgriMind / training /scripts /generate_multiturn.py
LightChuan's picture
Add files using upload-large-folder tool
5028850 verified
Raw
History Blame Contribute Delete
33.4 kB
#!/usr/bin/env python3
"""
generate_multiturn.py — 自动生成多轮问诊 (AVD) 对话数据
=========================================================
从 unified_dataset.jsonl 中按病害类别分组, 为同类别的多张图片
生成多轮追问→补充→诊断对话, 训练模型主动追问的能力.
生成两类样本:
1. 多轮追问正样本 (~1500): 模型认为信息不足, 追问后给出诊断
2. 无需追问负样本 (~500): 模型认为信息充分, 直接给出诊断
(主要从 PlantVillage 的清晰实验室照片中选取)
输出为 Qwen2.5-VL 对话格式 JSONL, 可直接用于 QLoRA 微调.
用法:
# DashScope API
python generate_multiturn.py \
--input data/processed/unified_dataset.jsonl \
--output data/generated/multiturn_avd.jsonl \
--api-key sk-xxx --max-samples 100
# 本地模型
python generate_multiturn.py \
--input data/processed/unified_dataset.jsonl \
--output data/generated/multiturn_avd.jsonl \
--use-local --max-samples 50
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import random
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
from tqdm import tqdm
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
SYSTEM_MULTITURN = (
"你是一位植物病理学家,正在进行远程问诊。当图片信息不足以做出确切诊断时,"
"你会主动追问,要求用户补充特定角度或部位的照片。当信息充分时,你会直接给出诊断。"
)
# Turn 1: initial observation + follow-up request
GENERATION_PROMPT_TURN1 = """\
你是一位植物病理学家,正在进行远程问诊。用户给你发了一张作物照片。
已知这是 {crop} 的照片,最终诊断应为: {label_cn}。
请分析这张照片,然后表示信息不充分,提出一个**具体的追问**:
- 要求用户拍摄特定部位或角度(如叶片背面、茎部基部、根部、果实近距离、病斑边缘等)
- 解释为什么需要这个补充信息
要求:
- 描述你在第一张图中观察到的2-3个初步症状
- 追问要有针对性, 例如"请拍叶片背面——我需要确认是否有孢子堆"
- 说明这个补充信息对确诊的意义
- 不要现在给出最终诊断
回复用纯文本,不要使用 markdown 格式标记。"""
# Turn 2: after receiving additional photo, give final diagnosis
GENERATION_PROMPT_TURN2 = """\
你是一位植物病理学家,正在进行远程问诊。
之前你观察了用户的第一张照片并追问了更多信息。你之前的回复是:
---
{turn1_response}
---
现在用户又发了一张补充照片(同一棵植物的另一个角度/部位)。
已知最终诊断为: {label_cn} ({label_en})
请基于两张照片的综合信息,给出:
1. 从补充照片中新观察到的关键特征
2. 结合两次观察的完整分析
3. 最终诊断结论及置信度
回复用纯文本,不要使用 markdown 格式标记。"""
# Negative: single clear image, no follow-up needed
GENERATION_PROMPT_NEGATIVE = """\
你是一位植物病理学家,正在进行远程问诊。用户给你发了一张作物照片。
已知最终诊断为: {label_cn} ({label_en})
这张照片信息充分、症状特征非常清晰,无需追问补充照片。
请直接给出完整的诊断:
1. 首先明确说明"图片信息充分,可以直接诊断"
2. 描述观察到的关键症状特征(至少3个)
3. 给出诊断结论及置信度
回复用纯文本,不要使用 markdown 格式标记。"""
# ---------------------------------------------------------------------------
# Image utilities
# ---------------------------------------------------------------------------
def encode_image_base64(image_path: str) -> str | None:
try:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
print(f" ⚠ 无法读取图片 {image_path}: {e}")
return None
def get_image_mime(image_path: str) -> str:
ext = Path(image_path).suffix.lower()
return {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".bmp": "image/bmp",
".webp": "image/webp",
}.get(ext, "image/jpeg")
# ---------------------------------------------------------------------------
# DashScope backend (native MultiModalConversation API)
# ---------------------------------------------------------------------------
class DashScopeBackend:
"""Generate via DashScope MultiModalConversation API."""
def __init__(self, api_key: str, model: str = "qwen2.5-vl-72b-instruct"):
self.api_key = api_key
self.model = model
self._request_times: list[float] = []
self._rpm_limit = 15
def _rate_limit(self):
now = time.time()
self._request_times = [t for t in self._request_times if now - t < 60]
if len(self._request_times) >= self._rpm_limit:
sleep_time = 60 - (now - self._request_times[0]) + 0.5
if sleep_time > 0:
time.sleep(sleep_time)
self._request_times.append(time.time())
def generate(self, prompt: str, image_path: str) -> str | None:
"""Call VLM with single image + prompt."""
try:
from dashscope import MultiModalConversation
except ImportError:
return self._generate_openai_compat(prompt, image_path)
self._rate_limit()
abs_path = str(Path(image_path).resolve())
messages = [
{
"role": "user",
"content": [
{"image": f"file://{abs_path}"},
{"text": prompt},
],
}
]
try:
response = MultiModalConversation.call(
model=self.model,
messages=messages,
api_key=self.api_key,
)
return response.output.choices[0].message.content[0]["text"].strip()
except Exception as e:
print(f" ⚠ DashScope API 调用失败: {e}")
return None
def generate_with_context(
self, prompt: str, image_path: str, context_messages: list[dict],
) -> str | None:
"""Call VLM with conversation history (for turn 2)."""
try:
from dashscope import MultiModalConversation
except ImportError:
return self._generate_with_context_openai(
prompt, image_path, context_messages,
)
self._rate_limit()
abs_path = str(Path(image_path).resolve())
messages = list(context_messages)
messages.append(
{
"role": "user",
"content": [
{"image": f"file://{abs_path}"},
{"text": prompt},
],
}
)
try:
response = MultiModalConversation.call(
model=self.model,
messages=messages,
api_key=self.api_key,
)
return response.output.choices[0].message.content[0]["text"].strip()
except Exception as e:
print(f" ⚠ DashScope API 调用失败: {e}")
return None
def _generate_openai_compat(self, prompt: str, image_path: str) -> str | None:
"""Fallback: OpenAI-compatible endpoint."""
try:
from openai import OpenAI
except ImportError:
print("错误: 请安装 dashscope 或 openai 库")
sys.exit(1)
b64 = encode_image_base64(image_path)
if b64 is None:
return None
mime = get_image_mime(image_path)
self._rate_limit()
client = OpenAI(
api_key=self.api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
try:
resp = client.chat.completions.create(
model=self.model,
messages=[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
},
{"type": "text", "text": prompt},
],
},
],
max_tokens=1500,
temperature=0.7,
)
return resp.choices[0].message.content.strip()
except Exception as e:
print(f" ⚠ OpenAI 兼容 API 调用失败: {e}")
return None
def _generate_with_context_openai(
self, prompt: str, image_path: str, context_messages: list[dict],
) -> str | None:
"""Fallback turn-2 via OpenAI-compatible endpoint."""
try:
from openai import OpenAI
except ImportError:
print("错误: 请安装 openai 库")
sys.exit(1)
b64 = encode_image_base64(image_path)
if b64 is None:
return None
mime = get_image_mime(image_path)
self._rate_limit()
# Convert context to OpenAI format
oai_messages: list[dict] = []
for msg in context_messages:
if msg["role"] == "assistant":
oai_messages.append(
{"role": "assistant", "content": msg["content"]}
)
elif msg["role"] == "user":
content_parts = msg.get("content", [])
oai_parts = []
for part in content_parts:
if "image" in part:
# Re-encode from path
img_p = part["image"]
if img_p.startswith("file://"):
img_p = img_p[7:]
ctx_b64 = encode_image_base64(img_p)
if ctx_b64:
ctx_mime = get_image_mime(img_p)
oai_parts.append({
"type": "image_url",
"image_url": {
"url": f"data:{ctx_mime};base64,{ctx_b64}",
},
})
elif "text" in part:
oai_parts.append({"type": "text", "text": part["text"]})
oai_messages.append({"role": "user", "content": oai_parts})
# Append current turn
oai_messages.append({
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
},
{"type": "text", "text": prompt},
],
})
client = OpenAI(
api_key=self.api_key,
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)
try:
resp = client.chat.completions.create(
model=self.model,
messages=oai_messages,
max_tokens=1500,
temperature=0.7,
)
return resp.choices[0].message.content.strip()
except Exception as e:
print(f" ⚠ OpenAI 兼容 API 调用失败: {e}")
return None
# ---------------------------------------------------------------------------
# Local backend (Qwen2.5-VL-7B, 4-bit)
# ---------------------------------------------------------------------------
class LocalBackend:
"""Generate responses using local Qwen2.5-VL-7B in 4-bit."""
def __init__(self, model_path: str | None = None):
self.model = None
self.processor = None
project_root = Path(__file__).resolve().parent.parent.parent
default_path = project_root / "models" / "qwen2.5-vl-7b"
self.model_path = model_path or str(default_path)
self._load_model()
def _load_model(self):
print("正在加载本地模型 Qwen2.5-VL-7B-Instruct (4-bit) ...")
try:
import torch
from transformers import (
AutoProcessor,
Qwen2_5_VLForConditionalGeneration,
BitsAndBytesConfig,
)
except ImportError as e:
print(f"错误: 缺少依赖: {e}")
print("请安装: pip install torch transformers bitsandbytes accelerate qwen-vl-utils")
sys.exit(1)
model_name = self.model_path if os.path.isdir(self.model_path) else "Qwen/Qwen2.5-VL-7B-Instruct"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
llm_int8_enable_fp32_cpu_offload=True,
)
self.processor = AutoProcessor.from_pretrained(
model_name, trust_remote_code=True,
)
max_memory = {0: "6GB", "cpu": "32GB"}
self.model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
max_memory=max_memory,
trust_remote_code=True,
torch_dtype=torch.float16,
)
self.model.eval()
print("模型加载完成。")
def _run_inference(self, messages: list[dict]) -> str | None:
import torch
from qwen_vl_utils import process_vision_info
try:
text = self.processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = self.processor(
text=[text],
images=image_inputs,
videos=video_inputs,
padding=True,
return_tensors="pt",
).to(self.model.device)
with torch.no_grad():
output_ids = self.model.generate(
**inputs,
max_new_tokens=1500,
temperature=0.7,
do_sample=True,
top_p=0.9,
)
generated = output_ids[0][inputs["input_ids"].shape[1]:]
result = self.processor.decode(
generated, skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)
return result.strip()
except Exception as e:
print(f" ⚠ 本地推理失败: {e}")
return None
def generate(self, prompt: str, image_path: str) -> str | None:
abs_path = str(Path(image_path).resolve())
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": f"file://{abs_path}"},
{"type": "text", "text": prompt},
],
}
]
return self._run_inference(messages)
def generate_with_context(
self, prompt: str, image_path: str, context_messages: list[dict],
) -> str | None:
abs_path = str(Path(image_path).resolve())
messages = list(context_messages)
messages.append(
{
"role": "user",
"content": [
{"type": "image", "image": f"file://{abs_path}"},
{"type": "text", "text": prompt},
],
}
)
return self._run_inference(messages)
# ---------------------------------------------------------------------------
# Data loading & resume
# ---------------------------------------------------------------------------
def load_input_data(input_file: str) -> list[dict]:
records = []
with open(input_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
records.append(json.loads(line))
return records
def load_existing_outputs(output_file: str) -> set[str]:
"""Load already-generated image paths for resume support.
Uses the FIRST image path in the first user message as unique key.
"""
done = set()
if not os.path.exists(output_file):
return done
try:
with open(output_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
entry = json.loads(line)
msgs = entry.get("messages", [])
for msg in msgs:
if msg.get("role") == "user":
content = msg.get("content", [])
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") == "image":
done.add(item.get("image", ""))
break # only first user message
except Exception:
pass
return done
def group_by_disease(records: list[dict]) -> dict[str, list[dict]]:
"""Group records by disease label (label_cn) for multiturn pairing."""
groups: dict[str, list[dict]] = defaultdict(list)
for rec in records:
groups[rec["label_cn"]].append(rec)
return dict(groups)
# ---------------------------------------------------------------------------
# Self-consistency filtering for multiturn
# ---------------------------------------------------------------------------
def extract_diagnosis(text: str, label_cn: str, label_en: str) -> str:
"""Extract diagnosis from text for consistency check."""
if label_cn in text:
return label_cn
if label_en.lower().replace("_", " ") in text.lower():
return label_en
for kw in ["最终诊断", "诊断为", "判断为", "确诊为", "结论"]:
idx = text.find(kw)
if idx != -1:
return text[idx: idx + 80].strip()
return text[-100:].strip()
def run_with_consistency(
gen_func, label_cn: str, label_en: str, n_runs: int = 3,
) -> str | None:
"""Run a generation function n_runs times, return result only if
the extracted diagnosis agrees across runs.
gen_func: callable() -> str | None
"""
responses: list[str] = []
labels: list[str] = []
for _ in range(n_runs):
resp = gen_func()
if resp is None:
return None
responses.append(resp)
labels.append(extract_diagnosis(resp, label_cn, label_en))
if len(set(labels)) == 1:
return responses[0]
counter = Counter(labels)
most_common, count = counter.most_common(1)[0]
if count >= n_runs - 1:
return responses[labels.index(most_common)]
return None # inconsistent
# ---------------------------------------------------------------------------
# Builders: Qwen2.5-VL SFT conversation format
# ---------------------------------------------------------------------------
def build_multiturn_record(
image1_path: str,
turn1_response: str,
image2_path: str,
turn2_response: str,
source: str,
label_en: str,
) -> dict:
"""Build multi-turn AVD positive sample (2 turns)."""
return {
"messages": [
{"role": "system", "content": SYSTEM_MULTITURN},
{
"role": "user",
"content": [
{"type": "image", "image": image1_path},
{"type": "text", "text": "这棵植物怎么了?"},
],
},
{"role": "assistant", "content": turn1_response},
{
"role": "user",
"content": [
{"type": "image", "image": image2_path},
{"type": "text", "text": "这是背面"},
],
},
{"role": "assistant", "content": turn2_response},
],
"metadata": {
"source": source,
"label": label_en,
"task_type": "multiturn_avd",
},
}
def build_negative_record(
image_path: str, response: str, source: str, label_en: str,
) -> dict:
"""Build no-follow-up negative sample (信息充分, 直接诊断)."""
return {
"messages": [
{"role": "system", "content": SYSTEM_MULTITURN},
{
"role": "user",
"content": [
{"type": "image", "image": image_path},
{"type": "text", "text": "这棵植物怎么了?"},
],
},
{"role": "assistant", "content": response},
],
"metadata": {
"source": source,
"label": label_en,
"task_type": "multiturn_avd_negative",
},
}
# ---------------------------------------------------------------------------
# Generation: multiturn positive samples
# ---------------------------------------------------------------------------
def generate_multiturn_positive(
groups: dict[str, list[dict]],
backend,
output_file: str,
max_samples: int | None,
consistency_runs: int,
) -> int:
"""Generate multi-turn AVD positive samples.
For each disease class with 2+ images, pair images and generate
a 2-turn dialogue: initial observation → follow-up request → diagnosis.
"""
done = load_existing_outputs(output_file)
# Build image pairs from same-disease groups
pairs: list[tuple[dict, dict, str]] = [] # (img1_rec, img2_rec, label_cn)
for label_cn, recs in groups.items():
if len(recs) < 2:
continue
random.shuffle(recs)
for i in range(0, len(recs) - 1, 2):
pairs.append((recs[i], recs[i + 1], label_cn))
random.shuffle(pairs)
# Filter already-done
pairs = [
(a, b, lbl)
for a, b, lbl in pairs
if a["image_path"] not in done
]
if max_samples is not None:
pairs = pairs[:max_samples]
print(f"\n多轮追问正样本: 待处理 {len(pairs)} 组 (已完成 {len(done)} 组)")
generated = 0
skipped = 0
with open(output_file, "a", encoding="utf-8") as fout:
for img1, img2, label_cn in tqdm(pairs, desc="生成多轮对话", unit="group"):
crop = img1.get("crop", "作物")
label_en = img1["label_en"]
source = img1["source"]
# --- Turn 1: initial observation + follow-up request ---
prompt1 = GENERATION_PROMPT_TURN1.format(
crop=crop, label_cn=label_cn,
)
if consistency_runs > 1:
turn1_resp = run_with_consistency(
lambda p=prompt1, ip=img1["image_path"]: backend.generate(p, ip),
label_cn, label_en, consistency_runs,
)
else:
turn1_resp = backend.generate(prompt1, img1["image_path"])
if turn1_resp is None:
skipped += 1
continue
# --- Turn 2: supplementary photo → final diagnosis ---
prompt2 = GENERATION_PROMPT_TURN2.format(
turn1_response=turn1_resp,
label_cn=label_cn,
label_en=label_en,
)
# Build conversation context for turn 2
abs1 = str(Path(img1["image_path"]).resolve())
context = [
{
"role": "user",
"content": [
{"image": f"file://{abs1}"},
{"text": "这棵植物怎么了?"},
],
},
{"role": "assistant", "content": turn1_resp},
]
full_prompt2 = "这是补充拍摄的照片。\n" + prompt2
if consistency_runs > 1:
turn2_resp = run_with_consistency(
lambda p=full_prompt2, ip=img2["image_path"], ctx=context: (
backend.generate_with_context(p, ip, ctx)
),
label_cn, label_en, consistency_runs,
)
else:
turn2_resp = backend.generate_with_context(
full_prompt2, img2["image_path"], context,
)
if turn2_resp is None:
skipped += 1
continue
entry = build_multiturn_record(
img1["image_path"], turn1_resp,
img2["image_path"], turn2_resp,
source, label_en,
)
fout.write(json.dumps(entry, ensure_ascii=False) + "\n")
fout.flush()
generated += 1
print(f" 正样本: 生成 {generated} 条, 跳过 {skipped} 条")
return generated
# ---------------------------------------------------------------------------
# Generation: negative samples (PlantVillage clear lab photos)
# ---------------------------------------------------------------------------
def generate_negative_samples(
records: list[dict],
backend,
output_file: str,
num_negative: int,
consistency_runs: int,
) -> int:
"""Generate negative samples where a single clear image suffices.
Prioritizes PlantVillage images (clear lab photos with uniform backgrounds)
since they are unambiguous and don't need follow-up questions.
"""
done = load_existing_outputs(output_file)
# Prioritize PlantVillage (clean lab photos), then others
pv_records = [r for r in records if r["source"] == "PlantVillage" and r["image_path"] not in done]
other_records = [r for r in records if r["source"] != "PlantVillage" and r["image_path"] not in done]
random.shuffle(pv_records)
random.shuffle(other_records)
# Take up to 80% from PlantVillage, rest from others
pv_count = min(len(pv_records), int(num_negative * 0.8))
other_count = min(len(other_records), num_negative - pv_count)
pending = pv_records[:pv_count] + other_records[:other_count]
# If we still need more, fill from whatever is left
if len(pending) < num_negative:
remaining = pv_records[pv_count:] + other_records[other_count:]
pending += remaining[: num_negative - len(pending)]
random.shuffle(pending)
print(f"\n无需追问负样本: 待处理 {len(pending)} 条 (PlantVillage 优先)")
generated = 0
skipped = 0
with open(output_file, "a", encoding="utf-8") as fout:
for rec in tqdm(pending, desc="生成负样本", unit="sample"):
img_path = rec["image_path"]
label_cn = rec["label_cn"]
label_en = rec["label_en"]
source = rec["source"]
prompt = GENERATION_PROMPT_NEGATIVE.format(
label_cn=label_cn, label_en=label_en,
)
if consistency_runs > 1:
response = run_with_consistency(
lambda p=prompt, ip=img_path: backend.generate(p, ip),
label_cn, label_en, consistency_runs,
)
else:
response = backend.generate(prompt, img_path)
if response is None:
skipped += 1
continue
# Validate: the response should mention "信息充分" or similar
if not any(kw in response for kw in ["信息充分", "直接诊断", "可以确定", "清晰可见", "特征明显"]):
# Retry once — the model may not have followed the format
response = backend.generate(prompt, img_path)
if response is None:
skipped += 1
continue
entry = build_negative_record(img_path, response, source, label_en)
fout.write(json.dumps(entry, ensure_ascii=False) + "\n")
fout.flush()
generated += 1
print(f" 负样本: 生成 {generated} 条, 跳过 {skipped} 条")
return generated
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="自动生成多轮问诊 (AVD) 对话训练数据",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
示例:
# DashScope API (测试 20 条)
python generate_multiturn.py \\
--input data/processed/unified_dataset.jsonl \\
--output data/generated/multiturn_avd.jsonl \\
--api-key sk-xxx --max-samples 20
# 本地 7B 模型
python generate_multiturn.py \\
--input data/processed/unified_dataset.jsonl \\
--output data/generated/multiturn_avd.jsonl \\
--use-local --max-samples 50
""",
)
parser.add_argument(
"--input",
type=str,
default="data/processed/unified_dataset.jsonl",
help="输入: unified_dataset.jsonl 路径",
)
parser.add_argument(
"--output",
type=str,
required=True,
help="输出 JSONL 文件路径",
)
parser.add_argument(
"--api-key",
type=str,
default=None,
help="DashScope API key (也可通过 DASHSCOPE_API_KEY 环境变量设置)",
)
parser.add_argument(
"--use-local",
action="store_true",
default=False,
help="使用本地 Qwen2.5-VL-7B 模型 (回退方案, 需要 GPU)",
)
parser.add_argument(
"--local-model-path",
type=str,
default=None,
help="本地模型路径 (默认: models/qwen2.5-vl-7b 相对于项目根)",
)
parser.add_argument(
"--max-samples",
type=int,
default=1500,
help="多轮正样本数量限制 (default: 1500)",
)
parser.add_argument(
"--num-negative",
type=int,
default=500,
help="无需追问负样本数量 (default: 500)",
)
parser.add_argument(
"--consistency-runs",
type=int,
default=3,
help="自一致性过滤: 对每个样本生成 N 次, 仅保留诊断一致的 (default: 3)",
)
parser.add_argument(
"--model",
type=str,
default="qwen2.5-vl-72b-instruct",
help="DashScope 模型名 (default: qwen2.5-vl-72b-instruct)",
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="随机种子 (default: 42)",
)
args = parser.parse_args()
random.seed(args.seed)
# Resolve API key
api_key = args.api_key or os.environ.get("DASHSCOPE_API_KEY")
if not args.use_local and not api_key:
print("错误: 请通过 --api-key 或 DASHSCOPE_API_KEY 环境变量提供 API key")
print(" 或使用 --use-local 回退到本地模型")
sys.exit(1)
# Validate input
input_path = Path(args.input)
if not input_path.exists():
print(f"错误: 输入文件不存在: {input_path}")
print("请先运行 prepare_data.py 生成 unified_dataset.jsonl")
sys.exit(1)
backend_cn = "本地 7B" if args.use_local else f"DashScope ({args.model})"
print("=" * 60)
print("多轮问诊 (AVD) 数据生成")
print("=" * 60)
print(f" 输入文件: {args.input}")
print(f" 输出文件: {args.output}")
print(f" 后端: {backend_cn}")
print(f" 正样本限制: {args.max_samples}")
print(f" 负样本数量: {args.num_negative}")
print(f" 一致性检验: {args.consistency_runs} 次/样本")
print()
# Load data
records = load_input_data(args.input)
print(f"加载 {len(records)} 条输入记录")
# Group by disease
groups = group_by_disease(records)
eligible = {k: v for k, v in groups.items() if len(v) >= 2}
print(
f"共 {len(groups)} 个病害类别, "
f"{len(eligible)} 个类别有 2+ 张图片可配对"
)
total_pairs = sum(len(v) // 2 for v in eligible.values())
print(f"预计可生成 ~{total_pairs} 组多轮对话")
# Initialize backend
if args.use_local:
backend = LocalBackend(model_path=args.local_model_path)
else:
backend = DashScopeBackend(api_key=api_key, model=args.model)
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
# Generate positive samples (multiturn follow-up)
n_pos = generate_multiturn_positive(
eligible, backend, args.output, args.max_samples, args.consistency_runs,
)
# Generate negative samples (PlantVillage clear photos → direct diagnosis)
n_neg = generate_negative_samples(
records, backend, args.output, args.num_negative, args.consistency_runs,
)
# Summary
print("\n" + "=" * 60)
print("生成摘要")
print("=" * 60)
print(f" 多轮正样本: {n_pos} 条")
print(f" 无需追问负样本: {n_neg} 条")
print(f" 合计: {n_pos + n_neg} 条")
print(f" 输出文件: {args.output}")
print("\n✓ 生成完成")
if __name__ == "__main__":
main()